diff --git a/Gemfile b/Gemfile index a5068e765..c4989c538 100644 --- a/Gemfile +++ b/Gemfile @@ -84,6 +84,7 @@ gem 'barnes' gem 'devise', '>= 4.9.4' gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot' gem 'devise_token_auth', '>= 1.2.3' +gem 'rails-i18n', '~> 7.0' # two-factor authentication gem 'devise-two-factor', '>= 5.0.0' # authorization diff --git a/Gemfile.lock b/Gemfile.lock index b77e5880f..7d29e0b02 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -727,6 +727,9 @@ GEM rails-html-sanitizer (1.6.1) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (7.0.10) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) railties (7.1.5.2) actionpack (= 7.1.5.2) activesupport (= 7.1.5.2) @@ -1125,6 +1128,7 @@ DEPENDENCIES rack-mini-profiler (>= 3.2.0) rack-timeout rails (~> 7.1) + rails-i18n (~> 7.0) redis redis-namespace responders (>= 3.1.1) diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index 532487a1b..5127ea612 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -44,7 +44,11 @@ class AccountBuilder end def create_account - @account = Account.create!(name: account_name, locale: I18n.locale) + @account = Account.create!( + name: account_name, + locale: I18n.locale, + custom_attributes: { 'onboarding_step' => 'account_details' } + ) Current.account = @account end diff --git a/app/builders/agent_builder.rb b/app/builders/agent_builder.rb index 2fe11cae0..d2715011c 100644 --- a/app/builders/agent_builder.rb +++ b/app/builders/agent_builder.rb @@ -29,8 +29,9 @@ class AgentBuilder user = User.from_email(email) return user if user + @name = email.split('@').first if @name.blank? temp_password = "1!aA#{SecureRandom.alphanumeric(12)}" - User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password) + User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password) end # Checks if the user needs confirmation. diff --git a/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb new file mode 100644 index 000000000..b45c16828 --- /dev/null +++ b/app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb @@ -0,0 +1,43 @@ +class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController + before_action :portal + before_action :check_authorization + before_action :set_articles, only: [:update_status, :delete_articles] + + def translate + head :not_implemented + end + + def update_status + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status]) + + ActiveRecord::Base.transaction do + @articles.find_each { |article| article.update!(status: params[:status]) } + end + head :ok + rescue ActiveRecord::RecordInvalid => e + render_could_not_create_error(e.message) + end + + def delete_articles + return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none? + + @articles.destroy_all + head :ok + end + + private + + def portal + @portal ||= Current.account.portals.find_by!(slug: params[:portal_id]) + end + + def check_authorization + authorize(Article, :create?) + end + + def set_articles + @articles = @portal.articles.where(id: params[:ids]) + end +end +Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController') diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index dd5346bd6..eafda0fe2 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController sort_on :phone_number, type: :string sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction] sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction] - sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] + sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction] sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction] sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction] diff --git a/app/controllers/api/v1/accounts/portals_controller.rb b/app/controllers/api/v1/accounts/portals_controller.rb index 972b244fa..ade83d8ec 100644 --- a/app/controllers/api/v1/accounts/portals_controller.rb +++ b/app/controllers/api/v1/accounts/portals_controller.rb @@ -61,9 +61,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController end def process_attached_logo - blob_id = params[:blob_id] - blob = ActiveStorage::Blob.find_signed(blob_id) - @portal.logo.attach(blob) + blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s) + @portal.logo.attach(blob) if blob end private diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 7176d6e1b..dddf3dd09 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -58,6 +58,7 @@ class Api::V1::AccountsController < Api::BaseController @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email)) @account.custom_attributes.merge!(custom_attributes_params) @account.settings.merge!(settings_params) + @account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.save! end @@ -71,9 +72,10 @@ class Api::V1::AccountsController < Api::BaseController private def enqueue_branding_enrichment - return if account_params[:email].blank? + email = account_params[:email].presence || @user&.email + return if email.blank? - Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email]) + Account::BrandingEnrichmentJob.perform_later(@account.id, email) Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30) rescue StandardError => e # Enrichment is optional — never let queue/Redis failures abort signup @@ -109,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController end def custom_attributes_params - params.permit(:industry, :company_size, :timezone) + params.permit(:industry, :company_size, :timezone, :referral_source, :user_role) end def settings_params diff --git a/app/controllers/api/v1/profile/mfa_controller.rb b/app/controllers/api/v1/profile/mfa_controller.rb index dd874f222..8480b64fb 100644 --- a/app/controllers/api/v1/profile/mfa_controller.rb +++ b/app/controllers/api/v1/profile/mfa_controller.rb @@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController before_action :check_mfa_feature_available before_action :check_mfa_enabled, only: [:destroy, :backup_codes] before_action :check_mfa_disabled, only: [:create, :verify] - before_action :validate_otp, only: [:verify, :backup_codes, :destroy] before_action :validate_password, only: [:destroy] + before_action :validate_otp, only: [:verify, :backup_codes, :destroy] def show; end @@ -48,7 +48,8 @@ class Api::V1::Profile::MfaController < Api::BaseController def validate_otp authenticated = Mfa::AuthenticationService.new( user: current_user, - otp_code: mfa_params[:otp_code] + otp_code: mfa_params[:otp_code], + backup_code: mfa_params[:backup_code] ).authenticate return if authenticated @@ -63,6 +64,6 @@ class Api::V1::Profile::MfaController < Api::BaseController end def mfa_params - params.permit(:otp_code, :password) + params.permit(:otp_code, :backup_code, :password) end end diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb index dd70a1ba5..594bb856e 100644 --- a/app/controllers/platform/api/v1/agent_bots_controller.rb +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController before_action :validate_platform_app_permissible, except: [:index, :create] def index - @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all + @resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible) end def show; end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index fcc8b463d..67e8d953d 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -7,7 +7,7 @@ module EmailHelper def render_email_html(content) return '' if content.blank? - ChatwootMarkdownRenderer.new(content).render_message.to_s + ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s end # Raise a standard error if any email address is invalid diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index a706e2df5..988829407 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -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 { diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue index e31d40d8a..325f6abe6 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue @@ -1,9 +1,13 @@ + + diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue new file mode 100644 index 000000000..c2551830d --- /dev/null +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticlePage/BulkTranslateDialog.vue @@ -0,0 +1,249 @@ + + + diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index e8f484997..22c142322 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -8,8 +8,6 @@ import { useEventListener } from '@vueuse/core'; import { ALLOWED_FILE_TYPES } from 'shared/constants/messages'; import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents'; import FileUpload from 'vue-upload-component'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; - import Button from 'dashboard/components-next/button/Button.vue'; import WhatsAppOptions from './WhatsAppOptions.vue'; import ContentTemplateSelector from './ContentTemplateSelector.vue'; @@ -30,6 +28,7 @@ const props = defineProps({ isDropdownActive: { type: Boolean, default: false }, messageSignature: { type: String, default: '' }, inboxId: { type: Number, default: null }, + voiceEnabled: { type: Boolean, default: false }, }); const emit = defineEmits([ @@ -82,11 +81,9 @@ const isRegularMessageMode = computed(() => { return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; }); -const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE); - const shouldShowSignatureButton = computed(() => { return ( - props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value + props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled ); }); @@ -111,7 +108,7 @@ watch( () => props.hasSelectedInbox, newValue => { nextTick(() => { - if (newValue && !isVoiceInbox.value) setSignature(); + if (newValue && !props.voiceEnabled) setSignature(); }); }, { immediate: true } diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index c4111b481..f7813332f 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -2,7 +2,7 @@ import { ref, computed } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { required, requiredIf } from '@vuelidate/validators'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox'; import { appendSignature, removeSignature, @@ -100,6 +100,8 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); const inboxMedium = computed(() => props.targetInbox?.medium || ''); +const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox)); + const effectiveChannelType = computed(() => getEffectiveChannelType(inboxChannelType.value, inboxMedium.value) ); @@ -442,6 +444,7 @@ useKeyboardEvents({ :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" :message-templates="whatsappMessageTemplates" :channel-type="inboxChannelType" + :voice-enabled="voiceCallEnabled" :is-loading="isCreating" :disable-send-button="isCreating" :has-selected-inbox="!!targetInbox" diff --git a/app/javascript/dashboard/components-next/filter/contactProvider.js b/app/javascript/dashboard/components-next/filter/contactProvider.js index a39000817..79933c138 100644 --- a/app/javascript/dashboard/components-next/filter/contactProvider.js +++ b/app/javascript/dashboard/components-next/filter/contactProvider.js @@ -135,6 +135,16 @@ export function useContactFilterContext() { filterOperators: containmentOperators.value, attributeModel: 'standard', }, + { + attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME, + value: CONTACT_ATTRIBUTES.COMPANY_NAME, + attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'), + label: t('CONTACTS_LAYOUT.FILTER.COMPANY'), + inputType: 'plainText', + dataType: 'text', + filterOperators: containmentOperators.value, + attributeModel: 'standard', + }, { attributeKey: CONTACT_ATTRIBUTES.CREATED_AT, value: CONTACT_ATTRIBUTES.CREATED_AT, diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js index 274eecb49..ba0dd24fa 100644 --- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js +++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js @@ -23,6 +23,7 @@ export const CONTACT_ATTRIBUTES = { IDENTIFIER: 'identifier', COUNTRY_CODE: 'country_code', CITY: 'city', + COMPANY_NAME: 'company_name', CREATED_AT: 'created_at', LAST_ACTIVITY_AT: 'last_activity_at', REFERER: 'referer', diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index a5b99f84f..d7a9c93ad 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -1,4 +1,5 @@ import { computed } from 'vue'; +import { isVoiceCallEnabled } from 'dashboard/helper/inbox'; export function useChannelIcon(inbox) { const channelTypeIconMap = { @@ -14,7 +15,6 @@ export function useChannelIcon(inbox) { 'Channel::Whatsapp': 'i-woot-whatsapp', 'Channel::Instagram': 'i-woot-instagram', 'Channel::Tiktok': 'i-woot-tiktok', - 'Channel::Voice': 'i-woot-voice', }; const providerIconMap = { @@ -38,6 +38,11 @@ export function useChannelIcon(inbox) { icon = 'i-woot-whatsapp'; } + // Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.) + if (isVoiceCallEnabled(inboxDetails)) { + icon = 'i-woot-voice'; + } + return icon ?? 'i-ri-global-fill'; }); diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index 8bccd84cc..38c001fae 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -19,8 +19,11 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-woot-whatsapp'); }); - it('returns correct icon for Voice channel', () => { - const inbox = { channel_type: 'Channel::Voice' }; + it('returns correct icon for voice-enabled Twilio channel', () => { + const inbox = { + channel_type: 'Channel::TwilioSms', + voice_enabled: true, + }; const { value: icon } = useChannelIcon(inbox); expect(icon).toBe('i-woot-voice'); }); diff --git a/app/javascript/dashboard/components-next/inline-input/InlineInput.vue b/app/javascript/dashboard/components-next/inline-input/InlineInput.vue index d65bb4257..9a9960c97 100644 --- a/app/javascript/dashboard/components-next/inline-input/InlineInput.vue +++ b/app/javascript/dashboard/components-next/inline-input/InlineInput.vue @@ -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(), }); @@ -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" diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index e776ed913..c349468b8 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -51,6 +51,7 @@ import { import { appendSignature, + collapseSelection, findNodeToInsertImage, getContentNode, insertAtCursor, @@ -66,6 +67,7 @@ import { import { hasPressedEnterAndNotCmdOrShift, hasPressedCommandAndEnter, + isEscape, } from 'shared/helpers/KeyboardHelpers'; import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; @@ -515,7 +517,9 @@ function setMenubarPosition({ selection } = {}) { function checkSelection(editorState) { showSelectionMenu.value = false; - const hasSelection = editorState.selection.from !== editorState.selection.to; + const { selection } = editorState; + // Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. + const hasSelection = !selection.empty && !selection.node; if (hasSelection === isTextSelected.value) return; isTextSelected.value = hasSelection; @@ -711,12 +715,17 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) { } function onKeydown(event) { + if (isEscape(event)) { + collapseSelection(editorView); + return true; + } if (isEnterToSendEnabled()) { handleLineBreakWhenEnterToSendEnabled(event); } if (isCmdPlusEnterToSendEnabled()) { handleLineBreakWhenCmdAndEnterToSendEnabled(event); } + return false; } function createEditorView() { @@ -744,6 +753,9 @@ function createEditorView() { blur: () => { if (props.disabled) return; typingIndicator.stop(); + // PM keeps its selection on blur — clear the menu flags manually. + isTextSelected.value = false; + editorRoot.value?.classList.remove('has-selection'); emit('blur'); }, paste: (view, event) => { diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue index 72104ff06..1e1ab9756 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue @@ -17,6 +17,8 @@ import { toggleMark } from 'prosemirror-commands'; import { wrapInList } from 'prosemirror-schema-list'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; +import { isEscape } from 'shared/helpers/KeyboardHelpers'; +import { collapseSelection } from 'dashboard/helper/editorHelper'; import { useAlert } from 'dashboard/composables'; import { useUISettings } from 'dashboard/composables/useUISettings'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; @@ -362,19 +364,33 @@ export default { onKeyup() { this.$emit('keyup'); }, - onKeydown() { + onKeydown(view, event) { this.$emit('keydown'); + if (isEscape(event)) { + if (this.showSlashMenu) { + this.showSlashMenu = false; + this.slashSearchTerm = ''; + this.slashMenuPosition = null; + return true; + } + collapseSelection(editorView); + return true; + } + return false; }, onBlur() { + // ProseMirror keeps its selection on blur — clear the menu flag manually. + this.isTextSelected = false; + this.$refs.editor?.classList.remove('has-selection'); this.$emit('blur'); }, onFocus() { this.$emit('focus'); }, checkSelection(editorState) { - const { from, to } = editorState.selection; - // Check if there's a selection (from and to are different) - const hasSelection = from !== to; + const { selection } = editorState; + // Skip NodeSelection (from Esc -> selectParentNode); only text ranges count. + const hasSelection = !selection.empty && !selection.node; // If the selection state is the same as the previous state, do nothing if (hasSelection === this.isTextSelected) return; // Update the selection state diff --git a/app/javascript/dashboard/composables/spec/useInbox.spec.js b/app/javascript/dashboard/composables/spec/useInbox.spec.js index 71472a35b..1c01032d1 100644 --- a/app/javascript/dashboard/composables/spec/useInbox.spec.js +++ b/app/javascript/dashboard/composables/spec/useInbox.spec.js @@ -47,7 +47,11 @@ const mockStore = createStore({ 11: { id: 11, channel_type: INBOX_TYPES.API }, 12: { id: 12, channel_type: INBOX_TYPES.SMS }, 13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM }, - 14: { id: 14, channel_type: INBOX_TYPES.VOICE }, + 14: { + id: 14, + channel_type: INBOX_TYPES.TWILIO, + voice_enabled: true, + }, 15: { id: 15, channel_type: INBOX_TYPES.TIKTOK }, }; return inboxes[id] || null; @@ -211,11 +215,11 @@ describe('useInbox', () => { }); expect(wrapper.vm.isAnInstagramChannel).toBe(true); - // Test Voice + // Test Voice (Twilio with voice_enabled) wrapper = mount(createTestComponent(14), { global: { plugins: [mockStore] }, }); - expect(wrapper.vm.isAVoiceChannel).toBe(true); + expect(wrapper.vm.voiceCallEnabled).toBe(true); // Test Tiktok wrapper = mount(createTestComponent(15), { @@ -274,7 +278,8 @@ describe('useInbox', () => { 'isAnEmailChannel', 'isAnInstagramChannel', 'isATiktokChannel', - 'isAVoiceChannel', + 'voiceCallEnabled', + 'voiceCallProvider', ]; expectedProperties.forEach(prop => { diff --git a/app/javascript/dashboard/composables/useInbox.js b/app/javascript/dashboard/composables/useInbox.js index 632d3556b..6e8d0a52a 100644 --- a/app/javascript/dashboard/composables/useInbox.js +++ b/app/javascript/dashboard/composables/useInbox.js @@ -1,7 +1,11 @@ import { computed } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; import { useCamelCase } from 'dashboard/composables/useTransformKeys'; -import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { + INBOX_TYPES, + isVoiceCallEnabled, + getVoiceCallProvider, +} from 'dashboard/helper/inbox'; export const INBOX_FEATURES = { REPLY_TO: 'replyTo', @@ -134,9 +138,9 @@ export const useInbox = (inboxId = null) => { return channelType.value === INBOX_TYPES.TIKTOK; }); - const isAVoiceChannel = computed(() => { - return channelType.value === INBOX_TYPES.VOICE; - }); + const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value)); + + const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); return { inbox, @@ -156,6 +160,7 @@ export const useInbox = (inboxId = null) => { isAnEmailChannel, isAnInstagramChannel, isATiktokChannel, - isAVoiceChannel, + voiceCallEnabled, + voiceCallProvider, }; }; diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index fd02461a8..378d303b4 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -109,11 +109,6 @@ export const FORMATTING = { 'redo', ], }, - 'Channel::Voice': { - marks: [], - nodes: [], - menu: [], - }, 'Channel::Tiktok': { marks: [], nodes: [], diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 0cc67db67..1f9632425 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -36,6 +36,7 @@ export const FEATURE_FLAGS = { CHATWOOT_V4: 'chatwoot_v4', CHANNEL_INSTAGRAM: 'channel_instagram', CHANNEL_TIKTOK: 'channel_tiktok', + CHANNEL_VOICE: 'channel_voice', CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team', CAPTAIN_CUSTOM_TOOLS: 'custom_tools', CAPTAIN_V2: 'captain_integration_v2', diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 97b7931fb..6dc47cfbf 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -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', +}); diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 991576e66..6feebb35d 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -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 }); diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index b3d071ccd..9839a04e0 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -2,6 +2,7 @@ import { messageSchema, MessageMarkdownTransformer, MessageMarkdownSerializer, + Selection, } from '@chatwoot/prosemirror-schema'; import { replaceVariablesInMessage } from '@chatwoot/utils'; import * as Sentry from '@sentry/vue'; @@ -273,6 +274,18 @@ export const scrollCursorIntoView = view => { } }; +/** + * Collapse the current selection to a cursor near its head. Used to override + * the default Escape -> selectParentNode behavior which would otherwise keep + * the text highlight visible. + * + * @param {EditorView} view - The ProseMirror EditorView + */ +export const collapseSelection = view => { + const { tr, selection } = view.state; + view.dispatch(tr.setSelection(Selection.near(selection.$head))); +}; + /** * Returns a transaction that inserts a node into editor at the given position * Has an optional param 'content' to check if the diff --git a/app/javascript/dashboard/helper/inbox.js b/app/javascript/dashboard/helper/inbox.js index 401739706..4039a07d1 100644 --- a/app/javascript/dashboard/helper/inbox.js +++ b/app/javascript/dashboard/helper/inbox.js @@ -11,9 +11,29 @@ export const INBOX_TYPES = { SMS: 'Channel::Sms', INSTAGRAM: 'Channel::Instagram', TIKTOK: 'Channel::Tiktok', - VOICE: 'Channel::Voice', }; +// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp) +export const VOICE_CALL_PROVIDERS = { + TWILIO: 'twilio', +}; + +export const getVoiceCallProvider = inbox => { + if (!inbox) return null; + + // Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes. + const channelType = inbox.channel_type || inbox.channelType; + const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled; + + if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) { + return VOICE_CALL_PROVIDERS.TWILIO; + } + + return null; +}; + +export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null; + export const TWILIO_CHANNEL_MEDIUM = { WHATSAPP: 'whatsapp', SMS: 'sms', @@ -30,7 +50,6 @@ const INBOX_ICON_MAP_FILL = { [INBOX_TYPES.LINE]: 'i-ri-line-fill', [INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill', [INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill', - [INBOX_TYPES.VOICE]: 'i-ri-phone-fill', }; const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill'; @@ -45,7 +64,6 @@ const INBOX_ICON_MAP_LINE = { [INBOX_TYPES.TELEGRAM]: 'i-woot-telegram', [INBOX_TYPES.LINE]: 'i-woot-line', [INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram', - [INBOX_TYPES.VOICE]: 'i-woot-voice', [INBOX_TYPES.TIKTOK]: 'i-woot-tiktok', }; @@ -58,7 +76,6 @@ export const getInboxSource = (type, phoneNumber, inbox) => { case INBOX_TYPES.TWILIO: case INBOX_TYPES.WHATSAPP: - case INBOX_TYPES.VOICE: return phoneNumber || ''; case INBOX_TYPES.EMAIL: @@ -97,9 +114,6 @@ export const getReadableInboxByType = (type, phoneNumber) => { case INBOX_TYPES.LINE: return 'line'; - case INBOX_TYPES.VOICE: - return 'voice'; - default: return 'chat'; } @@ -142,9 +156,6 @@ export const getInboxClassByType = (type, phoneNumber) => { case INBOX_TYPES.TIKTOK: return 'brand-tiktok'; - case INBOX_TYPES.VOICE: - return 'phone'; - default: return 'chat'; } diff --git a/app/javascript/dashboard/helper/portalHelper.js b/app/javascript/dashboard/helper/portalHelper.js index 37d09337f..33a2a822a 100644 --- a/app/javascript/dashboard/helper/portalHelper.js +++ b/app/javascript/dashboard/helper/portalHelper.js @@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = { action: 'archive', icon: 'i-lucide-archive-restore', }, + translate: { + label: + 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE', + value: 'translate', + action: 'translate', + icon: 'i-lucide-languages', + }, delete: { label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE', value: 'delete', @@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = { }; export const ARTICLE_MENU_OPTIONS = { - [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'], - [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'], - [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'], + [ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'], + [ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'], + [ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'], }; export const ARTICLE_TABS = { diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index f558dd213..b06c36d42 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,6 +16,7 @@ import { calculateMenuPosition, stripUnsupportedFormatting, stripInlineBase64Images, + collapseSelection, } from '../editorHelper'; import { FORMATTING } from 'dashboard/constants/editor'; import { EditorState } from '@chatwoot/prosemirror-schema'; @@ -454,6 +455,37 @@ describe('stripInlineBase64Images', () => { }); }); +describe('collapseSelection', () => { + it('collapses a text range to a cursor at its head', () => { + const editorView = new EditorView(document.body, { + state: createEditorState('Hello world'), + }); + + // Build a TextSelection via the initial selection's constructor (avoids + // importing prosemirror-state, which isn't a direct dep). + const { doc, selection } = editorView.state; + editorView.dispatch( + editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6)) + ); + expect(editorView.state.selection.empty).toBe(false); + + collapseSelection(editorView); + + expect(editorView.state.selection.empty).toBe(true); + expect(editorView.state.selection.head).toBe(6); + }); + + it('leaves an already-collapsed selection as a cursor', () => { + const editorView = new EditorView(document.body, { + state: createEditorState('Hi'), + }); + + collapseSelection(editorView); + + expect(editorView.state.selection.empty).toBe(true); + }); +}); + describe('insertAtCursor', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index e96a28b40..2c4852dc8 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -182,6 +182,7 @@ "BROWSER_LANGUAGE": "Browser Language", "MAIL_SUBJECT": "Email Subject", "COUNTRY_NAME": "Country", + "COMPANY_NAME": "Company", "REFERER_LINK": "Referrer Link", "ASSIGNEE_NAME": "Assignee", "TEAM_NAME": "Team", diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 9234071d1..1c69c4b29 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -387,6 +387,7 @@ "IDENTIFIER": "Identifier", "COUNTRY": "Country", "CITY": "City", + "COMPANY": "Company", "CREATED_AT": "Created at", "LAST_ACTIVITY": "Last activity", "REFERER_LINK": "Referer link", diff --git a/app/javascript/dashboard/i18n/locale/en/helpCenter.json b/app/javascript/dashboard/i18n/locale/en/helpCenter.json index ffeca222a..9ae849d25 100644 --- a/app/javascript/dashboard/i18n/locale/en/helpCenter.json +++ b/app/javascript/dashboard/i18n/locale/en/helpCenter.json @@ -525,6 +525,7 @@ "PUBLISH": "Publish", "DRAFT": "Draft", "ARCHIVE": "Archive", + "TRANSLATE": "Translate", "DELETE": "Delete" }, "STATUS": { @@ -579,6 +580,41 @@ "TITLE": "There are no articles in this category", "SUBTITLE": "Articles in this category will appear here" } + }, + "BULK_TRANSLATE": { + "TITLE": "Translate article | Translate {count} articles", + "DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.", + "LOCALE_LABEL": "Target language", + "LOCALE_PLACEHOLDER": "Select a language", + "CATEGORY_LABEL": "Target category", + "CATEGORY_PLACEHOLDER": "Select a category", + "OPTIONAL": "(optional)", + "CONFIRM": "Translate", + "SELECT_ALL": "Select all ({count})", + "SELECTED_COUNT": "{count} selected", + "CLEAR_SELECTION": "Clear selection", + "TRANSLATE_BUTTON": "Translate", + "CONFIRM_OVERWRITE": "Overwrite and translate", + "DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.", + "DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.", + "API": { + "SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.", + "ERROR_MESSAGE": "Failed to start translation. Please try again." + } + }, + "BULK_ACTIONS": { + "PUBLISH": "Publish", + "DRAFT": "Draft", + "ARCHIVE": "Archive", + "TRANSLATE": "Translate", + "DELETE": "Delete", + "STATUS_SUCCESS": "Articles updated successfully", + "STATUS_ERROR": "Failed to update articles", + "DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles", + "DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.", + "DELETE_CONFIRM": "Delete", + "DELETE_SUCCESS": "Articles deleted successfully", + "DELETE_ERROR": "Failed to delete articles" } }, "CATEGORY_PAGE": { diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 6b1ff20b1..24207cef2 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -636,7 +636,17 @@ "WIDGET_BUILDER": "Widget Builder", "BOT_CONFIGURATION": "Bot Configuration", "ACCOUNT_HEALTH": "Account Health", - "CSAT": "CSAT" + "CSAT": "CSAT", + "VOICE": "Voice" + }, + "VOICE_CONFIGURATION": { + "ENABLE_VOICE": { + "LABEL": "Enable Voice Calling", + "DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls." + }, + "CREDENTIALS": { + "DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections." + } }, "CHANNEL_PREFERENCES": "Channel Preferences", "WIDGET_FEATURES": "Widget features", diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 261ca85e6..31486a247 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -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, }; diff --git a/app/javascript/dashboard/i18n/locale/en/mfa.json b/app/javascript/dashboard/i18n/locale/en/mfa.json index b03917bcd..8e356aad4 100644 --- a/app/javascript/dashboard/i18n/locale/en/mfa.json +++ b/app/javascript/dashboard/i18n/locale/en/mfa.json @@ -51,10 +51,14 @@ }, "DISABLE": { "TITLE": "Disable Two-Factor Authentication", - "DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.", + "DESCRIPTION": "You'll need to enter your password and either a verification code from your authenticator app or a backup code to disable two-factor authentication.", "PASSWORD": "Password", "OTP_CODE": "Verification Code", "OTP_CODE_PLACEHOLDER": "000000", + "BACKUP_CODE": "Backup Code", + "BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes", + "USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead", + "USE_OTP_CODE": "Use a verification code from your authenticator app", "CONFIRM": "Disable 2FA", "CANCEL": "Cancel", "SUCCESS": "Two-factor authentication has been disabled", diff --git a/app/javascript/dashboard/i18n/locale/en/onboarding.json b/app/javascript/dashboard/i18n/locale/en/onboarding.json new file mode 100644 index 000000000..d7c960002 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/onboarding.json @@ -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." + } +} diff --git a/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue b/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue index a8ce1220b..485306797 100644 --- a/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue +++ b/app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue @@ -49,7 +49,6 @@ const navigateTo = computed(() => { const countriesMap = computed(() => { return countries.reduce((acc, country) => { - acc[country.code] = country; acc[country.id] = country; return acc; }, {}); diff --git a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js index 8d67ccc68..c5ea28774 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js +++ b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js @@ -53,6 +53,14 @@ const filterTypes = [ filterOperators: OPERATOR_TYPES_3, attribute_type: 'standard', }, + { + attributeKey: 'company_name', + attributeI18nKey: 'COMPANY', + inputType: 'plain_text', + dataType: 'text', + filterOperators: OPERATOR_TYPES_3, + attributeModel: 'standard', + }, { attributeKey: 'created_at', attributeI18nKey: 'CREATED_AT', @@ -124,6 +132,10 @@ export const filterAttributeGroups = [ key: 'city', i18nKey: 'CITY', }, + { + key: 'company_name', + i18nKey: 'COMPANY', + }, { key: 'created_at', i18nKey: 'CREATED_AT', diff --git a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js index 1882923bd..87bae7d11 100644 --- a/app/javascript/dashboard/routes/dashboard/dashboard.routes.js +++ b/app/javascript/dashboard/routes/dashboard/dashboard.routes.js @@ -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', diff --git a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue index 7d636de70..c0fc800e6 100644 --- a/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue +++ b/app/javascript/dashboard/routes/dashboard/helpcenter/pages/PortalsArticlesIndexPage.vue @@ -119,6 +119,7 @@ watch( :is-category-articles="isCategoryArticles" @page-change="onPageChange" @fetch-portal="fetchPortalAndItsCategories" + @refresh-articles="fetchArticles" /> diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue new file mode 100644 index 000000000..66ac0d57f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/Index.vue @@ -0,0 +1,417 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingFormRow.vue b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingFormRow.vue new file mode 100644 index 000000000..dbe3acd93 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingFormRow.vue @@ -0,0 +1,18 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingFormSelect.vue b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingFormSelect.vue new file mode 100644 index 000000000..a7264e55d --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingFormSelect.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue new file mode 100644 index 000000000..63b3fa391 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingLayout.vue @@ -0,0 +1,120 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue new file mode 100644 index 000000000..479aa4c81 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/OnboardingSection.vue @@ -0,0 +1,49 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/onboarding/constants.js b/app/javascript/dashboard/routes/dashboard/onboarding/constants.js new file mode 100644 index 000000000..b14255ccd --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/onboarding/constants.js @@ -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' }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js index c7f4529b8..3c073ec7e 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js @@ -74,6 +74,12 @@ export const AUTOMATIONS = { inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, + { + key: 'company_name', + name: 'COMPANY_NAME', + inputType: 'plain_text', + filterOperators: OPERATOR_TYPES_2, + }, { key: 'labels', name: 'LABELS', @@ -180,6 +186,12 @@ export const AUTOMATIONS = { inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, + { + key: 'company_name', + name: 'COMPANY_NAME', + inputType: 'plain_text', + filterOperators: OPERATOR_TYPES_2, + }, { key: 'referer', name: 'REFERER_LINK', @@ -314,6 +326,12 @@ export const AUTOMATIONS = { inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, + { + key: 'company_name', + name: 'COMPANY_NAME', + inputType: 'plain_text', + filterOperators: OPERATOR_TYPES_2, + }, { key: 'assignee_id', name: 'ASSIGNEE_NAME', @@ -460,6 +478,12 @@ export const AUTOMATIONS = { inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, + { + key: 'company_name', + name: 'COMPANY_NAME', + inputType: 'plain_text', + filterOperators: OPERATOR_TYPES_2, + }, { key: 'team_id', name: 'TEAM_NAME', @@ -590,6 +614,12 @@ export const AUTOMATIONS = { inputType: 'plain_text', filterOperators: OPERATOR_TYPES_6, }, + { + key: 'company_name', + name: 'COMPANY_NAME', + inputType: 'plain_text', + filterOperators: OPERATOR_TYPES_2, + }, { key: 'team_id', name: 'TEAM_NAME', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue index 838d3ac0b..884c198c4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Index.vue @@ -145,6 +145,7 @@ const openDelete = inbox => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index 11eb1f6aa..a26eb0e18 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -21,6 +21,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue'; import WeeklyAvailability from './components/WeeklyAvailability.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import ConfigurationPage from './settingsPage/ConfigurationPage.vue'; +import VoiceConfigurationPage from './settingsPage/VoiceConfigurationPage.vue'; import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue'; import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue'; import BotConfiguration from './components/BotConfiguration.vue'; @@ -46,6 +47,7 @@ export default { BotConfiguration, CollaboratorsPage, ConfigurationPage, + VoiceConfigurationPage, CustomerSatisfactionPage, FacebookReauthorize, GreetingsEditor, @@ -169,19 +171,17 @@ export default { }, ]; - if (!this.isAVoiceChannel) { - visibleToAllChannelTabs = [ - ...visibleToAllChannelTabs, - { - key: 'business-hours', - name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), - }, - { - key: 'csat', - name: this.$t('INBOX_MGMT.TABS.CSAT'), - }, - ]; - } + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'business-hours', + name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'), + }, + { + key: 'csat', + name: this.$t('INBOX_MGMT.TABS.CSAT'), + }, + ]; if (this.isAWebWidgetInbox) { visibleToAllChannelTabs = [ @@ -197,7 +197,6 @@ export default { this.isATwilioChannel || this.isALineChannel || this.isAPIInbox || - this.isAVoiceChannel || (this.isAnEmailChannel && !this.inbox.provider) || this.shouldShowWhatsAppConfiguration || this.isAWebWidgetInbox @@ -232,6 +231,24 @@ export default { ]; } + if ( + this.isATwilioChannel && + this.inbox.phone_number && + this.inbox.medium === 'sms' && + this.isFeatureEnabledonAccount( + this.accountId, + FEATURE_FLAGS.CHANNEL_VOICE + ) + ) { + visibleToAllChannelTabs = [ + ...visibleToAllChannelTabs, + { + key: 'voice-configuration', + name: this.$t('INBOX_MGMT.TABS.VOICE'), + }, + ]; + } + return visibleToAllChannelTabs; }, currentInboxId() { @@ -812,7 +829,6 @@ export default { @@ -1240,6 +1256,12 @@ export default { > +
+ +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue index 1804e1224..711217f32 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue @@ -12,6 +12,10 @@ const props = defineProps({ type: String, default: '', }, + voiceEnabled: { + type: Boolean, + default: false, + }, }); const getters = useStoreGetters(); const { t } = useI18n(); @@ -30,7 +34,6 @@ const i18nMap = { 'Channel::Api': 'API', 'Channel::Instagram': 'INSTAGRAM', 'Channel::Tiktok': 'TIKTOK', - 'Channel::Voice': 'VOICE', }; const twilioChannelName = () => { @@ -45,6 +48,9 @@ const readableChannelName = computed(() => { return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API'); } if (props.channelType === 'Channel::TwilioSms') { + if (props.voiceEnabled) { + return t('INBOX_MGMT.CHANNELS.VOICE'); + } return twilioChannelName(); } return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue index 0bfabb7b6..9cd1665bf 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/ConfigurationPage.vue @@ -208,24 +208,6 @@ export default {
-
- - - - - - -
+import { useAlert } from 'dashboard/composables'; +import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue'; +import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue'; +import NextInput from 'dashboard/components-next/input/Input.vue'; +import NextButton from 'dashboard/components-next/button/Button.vue'; + +export default { + components: { + SettingsFieldSection, + SettingsToggleSection, + NextInput, + NextButton, + }, + props: { + inbox: { + type: Object, + default: () => ({}), + }, + }, + data() { + return { + voiceEnabled: this.inbox.voice_enabled || false, + apiKeySid: this.inbox.api_key_sid || '', + apiKeySecret: '', + isUpdating: false, + }; + }, + computed: { + isVoiceConfigured() { + return !!this.inbox.voice_configured; + }, + hasApiKeySid() { + return !!this.inbox.api_key_sid; + }, + hasExistingCredentials() { + return this.hasApiKeySid && !!this.inbox.has_api_key_secret; + }, + needsCredentials() { + return ( + this.voiceEnabled && + !this.isVoiceConfigured && + !this.hasExistingCredentials + ); + }, + needsApiKeySid() { + return this.needsCredentials && !this.hasApiKeySid; + }, + isSubmitDisabled() { + if (!this.voiceEnabled) return false; + if (this.needsCredentials) { + if (this.needsApiKeySid && !this.apiKeySid) return true; + return !this.apiKeySecret; + } + return false; + }, + }, + watch: { + 'inbox.voice_enabled'(val) { + this.voiceEnabled = val || false; + }, + 'inbox.api_key_sid'(val) { + this.apiKeySid = val || ''; + }, + }, + methods: { + async updateVoiceSettings() { + this.isUpdating = true; + try { + const channelPayload = { voice_enabled: this.voiceEnabled }; + + if (this.needsCredentials) { + if (this.needsApiKeySid) { + channelPayload.api_key_sid = this.apiKeySid; + } + channelPayload.api_key_secret = this.apiKeySecret; + } + + await this.$store.dispatch('inboxes/updateInbox', { + id: this.inbox.id, + formData: false, + channel: channelPayload, + }); + this.apiKeySecret = ''; + useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE')); + } catch (error) { + useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')); + } finally { + this.isUpdating = false; + } + }, + }, +}; + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue index caf9e2a6a..b49bae086 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaManagementActions.vue @@ -31,6 +31,8 @@ const backupCodesDialogRef = ref(null); // Form values const disablePassword = ref(''); const disableOtpCode = ref(''); +const disableBackupCode = ref(''); +const useBackupCodeToDisable = ref(false); const regenerateOtpCode = ref(''); // Utility functions @@ -54,10 +56,17 @@ const downloadBackupCodes = () => { const handleDisableMfa = async () => { emit('disableMfa', { password: disablePassword.value, - otpCode: disableOtpCode.value, + otpCode: useBackupCodeToDisable.value ? '' : disableOtpCode.value, + backupCode: useBackupCodeToDisable.value ? disableBackupCode.value : '', }); }; +const toggleDisableMethod = () => { + useBackupCodeToDisable.value = !useBackupCodeToDisable.value; + disableOtpCode.value = ''; + disableBackupCode.value = ''; +}; + const handleRegenerateBackupCodes = async () => { emit('regenerateBackupCodes', { otpCode: regenerateOtpCode.value, @@ -68,6 +77,8 @@ const handleRegenerateBackupCodes = async () => { const resetDisableForm = () => { disablePassword.value = ''; disableOtpCode.value = ''; + disableBackupCode.value = ''; + useBackupCodeToDisable.value = false; disableDialogRef.value?.close(); }; @@ -157,12 +168,32 @@ defineExpose({ :label="$t('MFA_SETTINGS.DISABLE.PASSWORD')" /> + +
diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue index 80dce179b..bd21dfafb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MfaSettings.vue @@ -104,9 +104,9 @@ const cancelSetup = () => { }; // Disable MFA -const disableMfa = async ({ password, otpCode }) => { +const disableMfa = async ({ password, otpCode, backupCode }) => { try { - await mfaAPI.disable(password, otpCode); + await mfaAPI.disable(password, { otpCode, backupCode }); mfaEnabled.value = false; backupCodesGenerated.value = false; managementActionsRef.value?.resetDisableForm(); diff --git a/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue b/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue index e1c24303f..9473d16d2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/teams/AgentSelector.vue @@ -132,8 +132,10 @@ const headers = computed(() => [ -
-

+

+

{{ $t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', { selected: selectedAgents.length, diff --git a/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue b/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue index ef1e14663..19a7a325d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/teams/Create/AddAgents.vue @@ -88,7 +88,7 @@ export default {