Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2921db5bd4 | ||
|
|
3d7567bf23 | ||
|
|
cd9c8e3303 | ||
|
|
80fccbc526 | ||
|
|
bcdb73502e | ||
|
|
d634ced4e9 | ||
|
|
e723c6b6f2 | ||
|
|
5325e05143 | ||
|
|
c09206c22e | ||
|
|
568aae875b | ||
|
|
2324a344dc | ||
|
|
7c7d67fd06 | ||
|
|
b058d84034 | ||
|
|
0e122188e9 | ||
|
|
f8f0caf443 | ||
|
|
6aeda0ddf6 | ||
|
|
fed32d5964 | ||
|
|
735bc73c96 | ||
|
|
5e79dd699e | ||
|
|
05dd31389e | ||
|
|
f7bbd40816 | ||
|
|
7820739f3a | ||
|
|
224556fd1b | ||
|
|
51eb626b88 | ||
|
|
b0aa844a32 | ||
|
|
c8e551820b | ||
|
|
035d2858f5 | ||
|
|
16b8693e1b | ||
|
|
279dd1876c | ||
|
|
2266eb493b |
@@ -22,6 +22,7 @@ gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
gem 'gemoji'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
+5
-3
@@ -108,8 +108,8 @@ GEM
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
@@ -349,6 +349,7 @@ GEM
|
||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
gemoji (4.1.0)
|
||||
geocoder (1.8.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
@@ -676,7 +677,7 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
@@ -1075,6 +1076,7 @@ DEPENDENCIES
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,10 +80,17 @@ class DashboardController < ActionController::Base
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods,
|
||||
ACTIVE_PLATFORM_BANNERS: active_platform_banners
|
||||
}
|
||||
end
|
||||
|
||||
def active_platform_banners
|
||||
return [] unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
|
||||
end
|
||||
|
||||
def allowed_login_methods
|
||||
methods = ['email']
|
||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
|
||||
before_action :ensure_chatwoot_cloud
|
||||
|
||||
private
|
||||
|
||||
def ensure_chatwoot_cloud
|
||||
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
require 'administrate/base_dashboard'
|
||||
|
||||
class PlatformBannerDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
banner_message: Field::Text.with_options(truncate: 200),
|
||||
banner_type: Field::Select.with_options(collection: %w[info warning error]),
|
||||
active: Field::Boolean,
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime
|
||||
}.freeze
|
||||
|
||||
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
|
||||
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
|
||||
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
|
||||
|
||||
def display_resource(platform_banner)
|
||||
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import StatusBanner from './components/app/StatusBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -27,6 +28,7 @@ export default {
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
StatusBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
@@ -59,7 +61,6 @@ export default {
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
@@ -107,8 +108,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,12 +133,13 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
v-if="!authUIFlags.isFetching"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<StatusBanner />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, otpCode) {
|
||||
disable(password, { otpCode, backupCode } = {}) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode },
|
||||
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
@@ -34,9 +34,7 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
|
||||
+4
-21
@@ -23,10 +23,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
refreshTick: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -45,27 +41,14 @@ const isNewArticle = computed(() => !props.article?.id);
|
||||
const localTitle = ref(props.article?.title ?? '');
|
||||
const localContent = ref(props.article?.content ?? '');
|
||||
|
||||
const syncLocalState = () => {
|
||||
localTitle.value = props.article?.title ?? '';
|
||||
localContent.value = props.article?.content ?? '';
|
||||
};
|
||||
|
||||
// Sync local state when navigating to a different article or on initial fetch
|
||||
watch(
|
||||
() => props.article?.id,
|
||||
newId => {
|
||||
if (newId) syncLocalState();
|
||||
}
|
||||
);
|
||||
|
||||
// Parent bumps refreshTick after re-fetching on tab visibility. Skip
|
||||
// the sync while a save is in flight so we don't overwrite edits the
|
||||
// user just made.
|
||||
watch(
|
||||
() => props.refreshTick,
|
||||
() => {
|
||||
if (props.isUpdating) return;
|
||||
syncLocalState();
|
||||
if (newId) {
|
||||
localTitle.value = props.article?.title ?? '';
|
||||
localContent.value = props.article?.content ?? '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+3
-6
@@ -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 }
|
||||
|
||||
+4
-1
@@ -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"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -20,11 +18,13 @@ const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
slate:
|
||||
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
|
||||
amber:
|
||||
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -30,6 +30,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -82,6 +86,7 @@ onMounted(() => {
|
||||
|
||||
defineExpose({
|
||||
focus: () => inlineInputRef.value?.focus(),
|
||||
blur: () => inlineInputRef.value?.blur(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -106,6 +111,7 @@ defineExpose({
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
@@ -40,6 +41,16 @@ const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
const isHomeDashboardEnabled = computed(
|
||||
() => !!uiSettings.value?.home_dashboard_enabled
|
||||
);
|
||||
const unreadInboxCount = useMapGetter('notifications/getUnreadCount');
|
||||
const unreadInboxCountLabel = computed(() => {
|
||||
const count = unreadInboxCount.value;
|
||||
if (!count) return '';
|
||||
return count < 100 ? String(count) : '99+';
|
||||
});
|
||||
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
@@ -212,16 +223,28 @@ const reportRoutes = computed(() => newReportRoutes());
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
count: 'notifications/getUnreadCount',
|
||||
},
|
||||
},
|
||||
...(isHomeDashboardEnabled.value
|
||||
? [
|
||||
{
|
||||
name: 'Home',
|
||||
label: t('SIDEBAR.HOME'),
|
||||
icon: 'i-lucide-gauge',
|
||||
to: accountScopedRoute('home_dashboard'),
|
||||
activeOn: ['home_dashboard', 'home_dashboard_admin'],
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
name: 'Inbox',
|
||||
label: t('SIDEBAR.INBOX'),
|
||||
icon: 'i-lucide-inbox',
|
||||
to: accountScopedRoute('inbox_view'),
|
||||
activeOn: ['inbox_view', 'inbox_view_conversation'],
|
||||
getterKeys: {
|
||||
count: 'notifications/getUnreadCount',
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
name: 'Conversation',
|
||||
label: t('SIDEBAR.CONVERSATIONS'),
|
||||
@@ -858,6 +881,20 @@ const menuItems = computed(() => {
|
||||
:is-collapsed="isEffectivelyCollapsed"
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
<RouterLink
|
||||
v-if="isHomeDashboardEnabled"
|
||||
:to="accountScopedRoute('inbox_view')"
|
||||
:title="t('SIDEBAR.INBOX')"
|
||||
class="size-8 rounded-lg hover:bg-n-alpha-1 flex-shrink-0 grid place-content-center relative text-n-slate-11"
|
||||
>
|
||||
<span class="i-lucide-bell size-4" />
|
||||
<span
|
||||
v-if="unreadInboxCountLabel"
|
||||
class="min-h-2 min-w-2 p-0.5 px-1 bg-n-ruby-9 rounded-lg absolute -top-1 -right-1.5 grid place-items-center text-[9px] leading-none text-n-ruby-3"
|
||||
>
|
||||
{{ unreadInboxCountLabel }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Resize Handle (desktop only) -->
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const BANNER_COLOR_MAP = {
|
||||
info: 'blue',
|
||||
warning: 'amber',
|
||||
error: 'ruby',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const dismissedBannerIds = ref(
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
|
||||
);
|
||||
|
||||
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
|
||||
|
||||
const visibleBanners = computed(() => {
|
||||
const banners = globalConfig.value?.activePlatformBanners || [];
|
||||
return banners.filter(
|
||||
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
|
||||
);
|
||||
});
|
||||
|
||||
const formattedMessage = message =>
|
||||
new MessageFormatter(message).formattedMessage;
|
||||
|
||||
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
|
||||
|
||||
const dismissBanner = banner => {
|
||||
const key = dismissKey(banner);
|
||||
if (dismissedBannerIds.value.includes(key)) return;
|
||||
|
||||
dismissedBannerIds.value.push(key);
|
||||
LocalStorage.set(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
|
||||
dismissedBannerIds.value
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-for="banner in visibleBanners"
|
||||
:key="banner.id"
|
||||
:color="bannerColor(banner.banner_type)"
|
||||
:action-label="t('GENERAL_SETTINGS.DISMISS')"
|
||||
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
|
||||
@action="dismissBanner(banner)"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="formattedMessage(banner.banner_message)"
|
||||
class="text-xs"
|
||||
/>
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -109,11 +109,6 @@ export const FORMATTING = {
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Tiktok': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -153,3 +153,8 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
@@ -194,6 +195,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -387,6 +387,7 @@
|
||||
"IDENTIFIER": "Identifier",
|
||||
"COUNTRY": "Country",
|
||||
"CITY": "City",
|
||||
"COMPANY": "Company",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity",
|
||||
"REFERER_LINK": "Referer link",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"INBOX": {
|
||||
"LIST": {
|
||||
"TITLE": "My Inbox",
|
||||
"TITLE": "Notifications",
|
||||
"DISPLAY_DROPDOWN": "Display",
|
||||
"LOADING": "Fetching notifications",
|
||||
"404": "There are no active notifications in this group.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,10 @@
|
||||
"UPDATE_SUCCESS": "Your Language settings have been updated successfully",
|
||||
"UPDATE_ERROR": "There is an error while updating the language settings, please try again",
|
||||
"USE_ACCOUNT_DEFAULT": "Use account default"
|
||||
},
|
||||
"HOME_DASHBOARD": {
|
||||
"TITLE": "Home dashboard",
|
||||
"NOTE": "Show a Home dashboard with team workload, KPIs, and a Captain prompt as your landing page. Hides the My Inbox sidebar item."
|
||||
}
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
@@ -228,6 +232,128 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"HOME_DASHBOARD_PAGE": {
|
||||
"ROLE_SWITCH": {
|
||||
"AGENT": "Agent",
|
||||
"ADMIN": "Admin"
|
||||
},
|
||||
"GREETING": {
|
||||
"MORNING": "Good morning",
|
||||
"AFTERNOON": "Good afternoon",
|
||||
"EVENING": "Good evening",
|
||||
"LATE": "Working late",
|
||||
"FALLBACK_NAME": "there",
|
||||
"GREETING_LINE": "{greeting}, ",
|
||||
"NAME_LINE": "{name}."
|
||||
},
|
||||
"TEAM_SUMMARY": {
|
||||
"PREFIX": "Your team has ",
|
||||
"OPEN_CONVERSATIONS": "{count} open conversations",
|
||||
"MID": ", ",
|
||||
"NEARING_SLA": "{count} nearing SLA",
|
||||
"AND": ", and ",
|
||||
"AGENTS_ONLINE": "{count} agents online",
|
||||
"PERIOD": "."
|
||||
},
|
||||
"AGENT_SUMMARY": {
|
||||
"PREFIX": "You have ",
|
||||
"OPEN_CONVERSATIONS": "{count} open conversations",
|
||||
"MID": ", ",
|
||||
"UNATTENDED": "{count} unattended",
|
||||
"AND": ", and ",
|
||||
"NEARING_SLA": "{count} close to SLA breach",
|
||||
"PERIOD": "."
|
||||
},
|
||||
"KPI": {
|
||||
"OPEN_CONVERSATIONS": "Open conversations",
|
||||
"AVG_FIRST_RESPONSE": "Avg first response",
|
||||
"FIRST_RESPONSE": "First response",
|
||||
"RESOLVED_TODAY": "Resolved today",
|
||||
"NOTIFICATIONS": "Notifications",
|
||||
"SLA_BREACHES_TODAY": "SLA breaches today",
|
||||
"TEAM_CSAT_TODAY": "Team CSAT today",
|
||||
"SEE_ALL": "See all"
|
||||
},
|
||||
"AI": {
|
||||
"SHORTCUT": "⌘K"
|
||||
},
|
||||
"TEAM_WORKLOAD": {
|
||||
"TITLE": "Team workload",
|
||||
"SORT_BY": "Sort by",
|
||||
"HEADERS": {
|
||||
"AGENT": "Agent",
|
||||
"WORKLOAD": "Workload",
|
||||
"UNATTENDED_OPEN": "Unattended / Open",
|
||||
"FRT": "FRT",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"OFF_SHIFT": "Off shift",
|
||||
"CAPACITY": "{percent}% capacity",
|
||||
"YOU": "YOU"
|
||||
},
|
||||
"ATTENTION": {
|
||||
"TITLE": "Needs your attention",
|
||||
"SUBTITLE": "3 items, surfaced by Captain"
|
||||
},
|
||||
"SUGGESTIONS_ADMIN": {
|
||||
"OVERLOADED": "Who's overloaded right now?",
|
||||
"HIGH_VOLUME": "What's driving the {count} open conversations today?",
|
||||
"CSAT_OUTLIERS": "Show CSAT outliers this week",
|
||||
"TEAM_PERFORMANCE": "Summarize team performance today",
|
||||
"COACH_AGENT": "Draft a coaching note for {name}",
|
||||
"QUIET_DAY": "What did the team accomplish today?",
|
||||
"FIND_PATTERNS": "Trending customer questions"
|
||||
},
|
||||
"SUGGESTIONS_AGENT": {
|
||||
"SUMMARIZE_QUEUE": "Summarize my queue",
|
||||
"TRIAGE_UNATTENDED": "Help me triage my {count} unattended",
|
||||
"NEAR_SLA": "Which of my conversations are close to SLA breach?",
|
||||
"URGENT": "What's most urgent right now?",
|
||||
"SUMMARIZE_CONVERSATION": "Summarize my conversation with {name}",
|
||||
"CATCH_UP": "Catch me up on what I missed",
|
||||
"SLOW_DAY": "What can I work on?"
|
||||
},
|
||||
"AI_PLACEHOLDERS_ADMIN": {
|
||||
"DEFAULT": "Ask Captain about your team…",
|
||||
"SUMMARIZE": "Summarize the three most urgent tickets…",
|
||||
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
|
||||
"WHY_CSAT": "Why did CSAT slip today?",
|
||||
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
|
||||
},
|
||||
"AI_PLACEHOLDERS_AGENT": {
|
||||
"DEFAULT": "Ask anything about your queue…",
|
||||
"SUMMARIZE": "Summarize the three most urgent tickets…",
|
||||
"DRAFT_REPLY": "Draft a refund reply for Aria Chen…",
|
||||
"WHY_CSAT": "Why did CSAT slip today?",
|
||||
"FIND_TICKETS": "Find tickets mentioning rate limit this week…"
|
||||
},
|
||||
"SORT_OPTIONS_ADMIN": {
|
||||
"CAPACITY": "Capacity",
|
||||
"FIRST_RESPONSE": "First response",
|
||||
"CSAT_TODAY": "CSAT today",
|
||||
"RESOLVED_TODAY": "Resolved today"
|
||||
},
|
||||
"SORT_OPTIONS_AGENT": {
|
||||
"PRIORITY": "Priority",
|
||||
"SLA_BREACH": "SLA breach",
|
||||
"LATEST": "Latest activity",
|
||||
"OLDEST": "Oldest first",
|
||||
"CUSTOMER": "Customer name"
|
||||
},
|
||||
"TOP_CONVERSATIONS": {
|
||||
"TITLE": "Top conversations",
|
||||
"SLA_LEFT": "{time} left",
|
||||
"EMPTY": "Nothing in your queue.",
|
||||
"LOADING": "Loading…"
|
||||
},
|
||||
"COPILOT_THREAD": {
|
||||
"BACK": "Back to home",
|
||||
"SEND": "Send"
|
||||
},
|
||||
"RECENT_THREADS": {
|
||||
"TITLE": "Recent threads"
|
||||
}
|
||||
},
|
||||
"SIDEBAR_ITEMS": {
|
||||
"CHANGE_AVAILABILITY_STATUS": "Change",
|
||||
"CHANGE_ACCOUNTS": "Switch account",
|
||||
|
||||
@@ -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;
|
||||
}, {});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as companyRoutes } from './companies/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
import { routes as inboxRoutes } from './inbox/routes';
|
||||
import { routes as homeRoutes } from './home/routes';
|
||||
import { frontendURL } from '../../helper/URLHelper';
|
||||
import helpcenterRoutes from './helpcenter/helpcenter.routes';
|
||||
import campaignsRoutes from './campaigns/campaigns.routes';
|
||||
@@ -12,6 +13,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: [
|
||||
@@ -20,6 +22,7 @@ export default {
|
||||
component: AppContainer,
|
||||
children: [
|
||||
...captainRoutes,
|
||||
...homeRoutes,
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
...settings.routes,
|
||||
@@ -31,6 +34,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',
|
||||
|
||||
+3
-13
@@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useDocumentVisibility } from '@vueuse/core';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -14,7 +13,6 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
const { articleSlug, portalSlug } = route.params;
|
||||
|
||||
@@ -28,7 +26,6 @@ const portal = computed(() => portalBySlug.value(portalSlug));
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const isSaved = ref(false);
|
||||
const refreshTick = ref(0);
|
||||
|
||||
const articleLink = computed(() => {
|
||||
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
|
||||
@@ -87,12 +84,11 @@ const goBackToArticles = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchArticleDetails = async () => {
|
||||
await store.dispatch('articles/show', {
|
||||
const fetchArticleDetails = () => {
|
||||
store.dispatch('articles/show', {
|
||||
id: articleSlug,
|
||||
portalSlug,
|
||||
});
|
||||
refreshTick.value += 1;
|
||||
};
|
||||
|
||||
const previewArticle = () => {
|
||||
@@ -102,11 +98,6 @@ const previewArticle = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Re-fetch article when user returns to this tab
|
||||
watch(visibility, state => {
|
||||
if (state === 'visible') fetchArticleDetails();
|
||||
});
|
||||
|
||||
onMounted(fetchArticleDetails);
|
||||
</script>
|
||||
|
||||
@@ -115,7 +106,6 @@ onMounted(fetchArticleDetails);
|
||||
:article="article"
|
||||
:is-updating="isUpdating"
|
||||
:is-saved="isSaved"
|
||||
:refresh-tick="refreshTick"
|
||||
@save-article="saveArticle"
|
||||
@preview-article="previewArticle"
|
||||
@go-back="goBackToArticles"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
|
||||
import CopilotAgentMessage from 'dashboard/components-next/copilot/CopilotAgentMessage.vue';
|
||||
import CopilotAssistantMessage from 'dashboard/components-next/copilot/CopilotAssistantMessage.vue';
|
||||
import CopilotThinkingGroup from 'dashboard/components-next/copilot/CopilotThinkingGroup.vue';
|
||||
import CopilotLoader from 'dashboard/components-next/copilot/CopilotLoader.vue';
|
||||
import CopilotInput from 'dashboard/components-next/copilot/CopilotInput.vue';
|
||||
import ToggleCopilotAssistant from 'dashboard/components-next/copilot/ToggleCopilotAssistant.vue';
|
||||
|
||||
const props = defineProps({
|
||||
threadId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { accountScopedRoute } = useAccount();
|
||||
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const messages = computed(() =>
|
||||
store.getters['copilotMessages/getMessagesByThreadId'](props.threadId)
|
||||
);
|
||||
|
||||
const activeAssistant = computed(() => {
|
||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||
if (preferredId) {
|
||||
const found = assistants.value.find(a => a.id === preferredId);
|
||||
if (found) return found;
|
||||
}
|
||||
return assistants.value[0];
|
||||
});
|
||||
|
||||
const setAssistant = async assistant => {
|
||||
await updateUISettings({
|
||||
preferred_captain_assistant_id: assistant.id,
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessage = async message => {
|
||||
try {
|
||||
await store.dispatch('copilotMessages/create', {
|
||||
assistant_id: activeAssistant.value?.id,
|
||||
threadId: props.threadId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
router.push(accountScopedRoute('home_dashboard'));
|
||||
};
|
||||
|
||||
const groupedMessages = computed(() => {
|
||||
const result = [];
|
||||
let thinkingGroup = [];
|
||||
messages.value.forEach(message => {
|
||||
if (message.message_type === 'assistant_thinking') {
|
||||
thinkingGroup.push(message);
|
||||
} else {
|
||||
if (thinkingGroup.length) {
|
||||
result.push({
|
||||
id: thinkingGroup[0].id,
|
||||
message_type: 'thinking_group',
|
||||
messages: thinkingGroup,
|
||||
});
|
||||
thinkingGroup = [];
|
||||
}
|
||||
result.push(message);
|
||||
}
|
||||
});
|
||||
if (thinkingGroup.length) {
|
||||
result.push({
|
||||
id: thinkingGroup[0].id,
|
||||
message_type: 'thinking_group',
|
||||
messages: thinkingGroup,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const isLastMessageFromAssistant = computed(() => {
|
||||
const last = groupedMessages.value[groupedMessages.value.length - 1];
|
||||
return last?.message_type === 'assistant';
|
||||
});
|
||||
|
||||
const chatContainer = ref(null);
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
if (chatContainer.value) {
|
||||
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => messages.value, scrollToBottom, { deep: true });
|
||||
|
||||
onMounted(async () => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
try {
|
||||
const { data } = await CopilotMessagesAPI.get(props.threadId);
|
||||
const payload = data?.payload || [];
|
||||
payload.forEach(message => {
|
||||
store.dispatch('copilotMessages/upsert', message);
|
||||
});
|
||||
scrollToBottom();
|
||||
} catch {
|
||||
// best-effort fetch; rely on websocket if API errors
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full bg-n-surface-1 text-n-slate-12">
|
||||
<div
|
||||
class="flex items-center gap-2 px-6 py-3 border-b border-n-weak flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md text-[13px] font-medium text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 transition-colors bg-transparent border-0 cursor-pointer"
|
||||
@click="goHome"
|
||||
>
|
||||
<span class="i-lucide-chevron-left size-4" />
|
||||
{{ $t('HOME_DASHBOARD_PAGE.COPILOT_THREAD.BACK') }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="chatContainer"
|
||||
class="flex-1 min-h-0 w-full overflow-y-auto text-sm leading-6 tracking-tight"
|
||||
>
|
||||
<div class="w-full max-w-[820px] mx-auto px-6 py-6">
|
||||
<div
|
||||
v-if="groupedMessages.length"
|
||||
class="space-y-6 flex flex-col w-full"
|
||||
>
|
||||
<template v-for="(item, index) in groupedMessages" :key="item.id">
|
||||
<CopilotAgentMessage
|
||||
v-if="item.message_type === 'user'"
|
||||
:message="item.message"
|
||||
/>
|
||||
<CopilotAssistantMessage
|
||||
v-else-if="item.message_type === 'assistant'"
|
||||
:message="item.message"
|
||||
:is-last-message="index === groupedMessages.length - 1"
|
||||
conversation-inbox-type=""
|
||||
/>
|
||||
<CopilotThinkingGroup
|
||||
v-else
|
||||
:messages="item.messages"
|
||||
:default-collapsed="isLastMessageFromAssistant"
|
||||
/>
|
||||
</template>
|
||||
<CopilotLoader v-if="!isLastMessageFromAssistant" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center h-full text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('HOME_DASHBOARD_PAGE.TOP_CONVERSATIONS.LOADING') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full flex-shrink-0">
|
||||
<div class="w-full max-w-[820px] mx-auto px-6 pt-px pb-4">
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<ToggleCopilotAssistant
|
||||
v-if="assistants.length > 1"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant || {}"
|
||||
@set-assistant="setAssistant"
|
||||
/>
|
||||
<div v-else />
|
||||
</div>
|
||||
<CopilotInput
|
||||
v-if="assistants.length"
|
||||
class="w-full"
|
||||
@send="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { ROLES } from 'dashboard/constants/permissions.js';
|
||||
import HomeDashboard from './HomeDashboard.vue';
|
||||
import HomeCopilotThread from './HomeCopilotThread.vue';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/home'),
|
||||
name: 'home_dashboard',
|
||||
component: HomeDashboard,
|
||||
meta: {
|
||||
permissions: ROLES,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/home/admin'),
|
||||
name: 'home_dashboard_admin',
|
||||
component: HomeDashboard,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/home/copilot/:threadId'),
|
||||
name: 'home_copilot_thread',
|
||||
component: HomeCopilotThread,
|
||||
props: true,
|
||||
meta: {
|
||||
permissions: ROLES,
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,417 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import OnboardingLayout from './OnboardingLayout.vue';
|
||||
import OnboardingSection from './OnboardingSection.vue';
|
||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import {
|
||||
COMPANY_SIZE_OPTIONS,
|
||||
INDUSTRY_OPTIONS,
|
||||
REFERRAL_SOURCE_OPTIONS,
|
||||
USER_ROLE_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { accountId, currentAccount, updateAccount } = useAccount();
|
||||
const { enabledLanguages } = useConfig();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const userRole = ref('');
|
||||
const website = ref('');
|
||||
const locale = ref('');
|
||||
const timezone = ref('');
|
||||
const companySize = ref('');
|
||||
const industry = ref('');
|
||||
const referralSource = ref('');
|
||||
const isSubmitting = ref(false);
|
||||
const isEditingWebsite = ref(false);
|
||||
const websiteInput = ref(null);
|
||||
const showErrorOnFields = ref(false);
|
||||
|
||||
const validationRules = {
|
||||
userRole: {},
|
||||
website: {},
|
||||
locale: {},
|
||||
timezone: {},
|
||||
companySize: {},
|
||||
industry: {},
|
||||
referralSource: {},
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, {
|
||||
userRole,
|
||||
website,
|
||||
locale,
|
||||
timezone,
|
||||
companySize,
|
||||
industry,
|
||||
referralSource,
|
||||
});
|
||||
|
||||
const userName = computed(() => currentUser.value?.name || '');
|
||||
const userEmail = computed(() => currentUser.value?.email || '');
|
||||
const accountName = computed(() => currentAccount.value?.name || '');
|
||||
const enrichmentTimedOut = ref(false);
|
||||
const isEnriching = computed(
|
||||
() =>
|
||||
!enrichmentTimedOut.value &&
|
||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||
);
|
||||
const companyLogo = computed(() => {
|
||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||
if (!logos?.length) return '';
|
||||
const square = logos.find(l => l.resolution?.aspect_ratio === 1);
|
||||
return (square || logos[0])?.url || '';
|
||||
});
|
||||
|
||||
const languageOptions = computed(() => {
|
||||
const langs = [...(enabledLanguages || [])];
|
||||
return langs
|
||||
.sort((a, b) => a.iso_639_1_code.localeCompare(b.iso_639_1_code))
|
||||
.map(l => ({ value: l.iso_639_1_code, label: l.name }));
|
||||
});
|
||||
|
||||
const timezoneOptions = computed(() => {
|
||||
try {
|
||||
return Intl.supportedValuesOf('timeZone').map(tz => ({
|
||||
value: tz,
|
||||
label: tz.replace(/_/g, ' '),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Best-effort match browser language to enabled Chatwoot locales.
|
||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
||||
// falls back to account locale or 'en'.
|
||||
const detectBestLocale = () => {
|
||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||
const browserLang = navigator.language?.replace('-', '_');
|
||||
const accountLocale = currentAccount.value?.locale || 'en';
|
||||
if (!browserLang) return accountLocale;
|
||||
|
||||
if (codes.includes(browserLang)) return browserLang;
|
||||
const base = browserLang.split('_')[0];
|
||||
if (codes.includes(base)) return base;
|
||||
|
||||
return accountLocale;
|
||||
};
|
||||
|
||||
// Snapshot of auto-populated values to detect user edits at submit time
|
||||
const initialValues = ref({});
|
||||
|
||||
const snapshotInitialValues = () => {
|
||||
initialValues.value = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
};
|
||||
|
||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||
// populates untouched fields without clobbering user edits.
|
||||
const populateFormFields = () => {
|
||||
const account = currentAccount.value;
|
||||
const attrs = account?.custom_attributes || {};
|
||||
const brandInfo = attrs.brand_info;
|
||||
|
||||
if (!locale.value) locale.value = detectBestLocale();
|
||||
if (!website.value) {
|
||||
website.value = account?.domain || brandInfo?.domain || '';
|
||||
}
|
||||
if (!timezone.value) {
|
||||
timezone.value =
|
||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
}
|
||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
||||
if (!industry.value) {
|
||||
industry.value =
|
||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
||||
}
|
||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
||||
|
||||
snapshotInitialValues();
|
||||
};
|
||||
|
||||
let enrichmentTimer = null;
|
||||
|
||||
const startEnrichmentTimer = () => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
enrichmentTimer = setTimeout(() => {
|
||||
enrichmentTimedOut.value = true;
|
||||
populateFormFields();
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
populateFormFields();
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||
if (isEnriching.value) startEnrichmentTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
});
|
||||
|
||||
watch(isEnriching, newVal => {
|
||||
if (newVal) {
|
||||
startEnrichmentTimer();
|
||||
} else {
|
||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||
populateFormFields();
|
||||
}
|
||||
});
|
||||
|
||||
// Re-populate when account data arrives after mount, or when brand_info
|
||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||
watch(
|
||||
() => currentAccount.value?.custom_attributes,
|
||||
() => populateFormFields()
|
||||
);
|
||||
|
||||
const enableWebsiteEditing = () => {
|
||||
isEditingWebsite.value = true;
|
||||
nextTick(() => websiteInput.value?.focus());
|
||||
};
|
||||
|
||||
const handleWebsiteEnter = () => {
|
||||
websiteInput.value?.blur();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Block submit while enrichment is still running so users can't bypass
|
||||
// the form with empty values — the controller would otherwise clear
|
||||
// onboarding_step and persist incomplete data.
|
||||
if (isEnriching.value) return;
|
||||
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) {
|
||||
useAlert(t('ONBOARDING_NEXT.VALIDATION_ERROR'));
|
||||
showErrorOnFields.value = true;
|
||||
setTimeout(() => {
|
||||
showErrorOnFields.value = false;
|
||||
}, 600);
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await updateAccount({
|
||||
name: accountName.value,
|
||||
locale: locale.value,
|
||||
domain: website.value,
|
||||
industry: industry.value,
|
||||
company_size: companySize.value,
|
||||
timezone: timezone.value,
|
||||
referral_source: referralSource.value,
|
||||
user_role: userRole.value,
|
||||
});
|
||||
|
||||
const init = initialValues.value;
|
||||
const enrichableFields = {
|
||||
website: website.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
};
|
||||
|
||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||
has_enriched_data: Boolean(
|
||||
currentAccount.value?.custom_attributes?.brand_info
|
||||
),
|
||||
fields_changed: Object.entries(enrichableFields)
|
||||
.filter(([key, val]) => val !== init[key])
|
||||
.map(([key]) => key),
|
||||
user_role: userRole.value,
|
||||
company_size: companySize.value,
|
||||
industry: industry.value,
|
||||
referral_source: referralSource.value,
|
||||
});
|
||||
|
||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||
store.commit('RESET_ONBOARDING', accountId.value);
|
||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
||||
} catch {
|
||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<OnboardingLayout
|
||||
:greeting="t('ONBOARDING_NEXT.GREETING', { name: userName })"
|
||||
:subtitle="t('ONBOARDING_NEXT.SUBTITLE')"
|
||||
:continue-label="t('ONBOARDING_NEXT.CONTINUE')"
|
||||
:is-loading="isSubmitting"
|
||||
:disabled="isEnriching"
|
||||
>
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.YOUR_DETAILS')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<Avatar :name="userName" :size="16" rounded-full />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ userName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.EMAIL')"
|
||||
icon="i-lucide-mail"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
<span class="text-sm text-n-slate-12">{{ userEmail }}</span>
|
||||
<Icon
|
||||
v-tooltip="t('ONBOARDING_NEXT.EMAIL_VERIFIED')"
|
||||
icon="i-lucide-circle-check"
|
||||
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.YOUR_ROLE')"
|
||||
icon="i-lucide-user"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="userRole"
|
||||
:has-error="showErrorOnFields && v$.userRole.$error"
|
||||
:options="USER_ROLE_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_ROLE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</OnboardingSection>
|
||||
|
||||
<OnboardingSection
|
||||
:title="t('ONBOARDING_NEXT.COMPANY_DETAILS')"
|
||||
icon="i-lucide-briefcase-business"
|
||||
>
|
||||
<div
|
||||
v-if="isEnriching"
|
||||
class="flex items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<Spinner :size="16" class="text-n-blue-10" />
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('ONBOARDING_NEXT.SETTING_UP') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-2 px-3 py-3">
|
||||
<img
|
||||
v-if="companyLogo"
|
||||
:src="companyLogo"
|
||||
:alt="accountName"
|
||||
class="size-4 object-contain"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ accountName }}
|
||||
</span>
|
||||
</div>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.WEBSITE')"
|
||||
icon="i-lucide-globe"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<InlineInput
|
||||
ref="websiteInput"
|
||||
v-model="website"
|
||||
:readonly="!isEditingWebsite"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.ENTER_WEBSITE')"
|
||||
:custom-input-class="[
|
||||
'w-auto text-end px-1 py-0.5 -my-0.5 mx-0 placeholder:text-n-slate-9 rounded',
|
||||
{ 'animate-shake': showErrorOnFields && v$.website.$error },
|
||||
]"
|
||||
@enter-press="handleWebsiteEnter"
|
||||
@blur="isEditingWebsite = false"
|
||||
/>
|
||||
<NextButton
|
||||
type="button"
|
||||
icon="i-lucide-pencil"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="enableWebsiteEditing"
|
||||
/>
|
||||
</div>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.LANGUAGE')"
|
||||
icon="i-lucide-languages"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="locale"
|
||||
:has-error="showErrorOnFields && v$.locale.$error"
|
||||
:options="languageOptions"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.TIMEZONE')"
|
||||
icon="i-lucide-clock"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="timezone"
|
||||
:has-error="showErrorOnFields && v$.timezone.$error"
|
||||
:options="timezoneOptions"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_TIMEZONE')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.INDUSTRY')"
|
||||
icon="i-lucide-factory"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="industry"
|
||||
:has-error="showErrorOnFields && v$.industry.$error"
|
||||
:options="INDUSTRY_OPTIONS"
|
||||
:placeholder="t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_INDUSTRY')"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.COMPANY_SIZE')"
|
||||
icon="i-lucide-users"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="companySize"
|
||||
:has-error="showErrorOnFields && v$.companySize.$error"
|
||||
:options="COMPANY_SIZE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_COMPANY_SIZE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
<OnboardingFormRow
|
||||
:title="t('ONBOARDING_NEXT.FIELDS.REFERRAL_SOURCE')"
|
||||
icon="i-lucide-megaphone"
|
||||
>
|
||||
<OnboardingFormSelect
|
||||
v-model="referralSource"
|
||||
:has-error="showErrorOnFields && v$.referralSource.$error"
|
||||
:options="REFERRAL_SOURCE_OPTIONS"
|
||||
:placeholder="
|
||||
t('ONBOARDING_NEXT.PLACEHOLDERS.SELECT_REFERRAL_SOURCE')
|
||||
"
|
||||
/>
|
||||
</OnboardingFormRow>
|
||||
</template>
|
||||
</OnboardingSection>
|
||||
</OnboardingLayout>
|
||||
</form>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 items-center px-3 py-3 border-t border-n-weak">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon :icon="icon" class="size-4 text-n-slate-9 flex-shrink-0" />
|
||||
<span class="text-n-slate-11">{{ title }}</span>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
hasError: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end">
|
||||
<select
|
||||
:value="modelValue"
|
||||
class="!h-auto !w-auto !py-0 !ps-0 !pe-[17px] !m-0 !rounded-none !bg-transparent !bg-none !outline-none text-sm text-end border-0 cursor-pointer appearance-none focus:outline-none focus:ring-0"
|
||||
:class="[
|
||||
modelValue ? 'text-n-slate-12' : 'text-n-slate-9',
|
||||
{ 'animate-shake': hasError },
|
||||
]"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
>
|
||||
<option v-if="placeholder" value="" disabled>
|
||||
{{ placeholder }}
|
||||
</option>
|
||||
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<Icon
|
||||
icon="i-lucide-chevron-down"
|
||||
class="pointer-events-none absolute end-0 top-1/2 -translate-y-1/2 text-n-slate-9"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
greeting: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
continueLabel: { type: String, default: 'Continue' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
defineEmits(['continue']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex text-body-main items-start justify-center w-full min-h-screen bg-n-surface-2 py-12 px-4 overflow-hidden"
|
||||
>
|
||||
<!-- Grid background with corner fade -->
|
||||
<div
|
||||
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
||||
/>
|
||||
<div class="relative w-full max-w-[580px]">
|
||||
<div class="relative ps-12">
|
||||
<!-- Timeline dotted line -->
|
||||
<svg
|
||||
class="absolute start-[16px] top-10 bottom-20 overflow-visible text-n-slate-5"
|
||||
width="1"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="97%"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div class="mb-6 -ms-12 flex items-start gap-4">
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 z-10 flex-shrink-0"
|
||||
>
|
||||
<slot name="greeting-icon">
|
||||
<span class="i-woot-onboarding-greeting size-4 text-n-slate-7" />
|
||||
</slot>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-heading-1 text-n-slate-12">
|
||||
{{ greeting }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="text-sm text-n-slate-11">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sections -->
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Continue button with curved connector -->
|
||||
<div class="relative ps-12 overflow-visible">
|
||||
<!-- Curved line (absolutely positioned, doesn't affect layout) -->
|
||||
<svg
|
||||
width="48"
|
||||
height="40"
|
||||
viewBox="0 0 47 40"
|
||||
fill="none"
|
||||
class="absolute start-0 top-0 overflow-visible rtl:-scale-x-100"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="line-gradient"
|
||||
x1="15"
|
||||
y1="0"
|
||||
x2="48"
|
||||
y2="20"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stop-color="rgb(var(--slate-5))" />
|
||||
<stop offset="100%" stop-color="rgb(var(--blue-9))" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M15.5 0 C15.5 24, 15.5 20, 48 20"
|
||||
stroke="url(#line-gradient)"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="3 3"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Triangle pointer (positioned at button's leading edge, pointing inward) -->
|
||||
<svg
|
||||
width="6"
|
||||
height="6"
|
||||
viewBox="0 0 6 6"
|
||||
fill="none"
|
||||
class="absolute start-[42px] top-1/2 -translate-y-1/2 z-10 rtl:-scale-x-100"
|
||||
>
|
||||
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
||||
</svg>
|
||||
<NextButton
|
||||
type="submit"
|
||||
blue
|
||||
:is-loading="isLoading"
|
||||
:disabled="disabled"
|
||||
class="w-full justify-center"
|
||||
@click="$emit('continue')"
|
||||
>
|
||||
{{ continueLabel }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
icon: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-5">
|
||||
<!-- Section header with icon + triangles -->
|
||||
<div class="flex items-center gap-4 mb-3 -ms-12">
|
||||
<div class="flex flex-col items-center z-10 flex-shrink-0">
|
||||
<svg
|
||||
width="6"
|
||||
height="5"
|
||||
viewBox="0 0 6 5"
|
||||
fill="none"
|
||||
class="text-n-slate-5"
|
||||
>
|
||||
<path d="M3 0L6 5H0L3 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
<div
|
||||
class="flex items-center justify-center w-8 h-8 rounded-lg bg-n-solid-1 border border-n-weak"
|
||||
>
|
||||
<Icon :icon="icon" class="size-4 text-n-slate-11" />
|
||||
</div>
|
||||
<svg
|
||||
width="6"
|
||||
height="5"
|
||||
viewBox="0 0 6 5"
|
||||
fill="none"
|
||||
class="text-n-slate-5"
|
||||
>
|
||||
<path d="M3 5L0 0H6L3 5Z" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
export const COMPANY_SIZE_OPTIONS = [
|
||||
{ value: '1-10', label: '1 - 10' },
|
||||
{ value: '11-50', label: '11 - 50' },
|
||||
{ value: '51-200', label: '51 - 200' },
|
||||
{ value: '201-500', label: '201 - 500' },
|
||||
{ value: '500+', label: '500+' },
|
||||
];
|
||||
|
||||
export const INDUSTRY_OPTIONS = [
|
||||
{ value: 'Aerospace & Defense', label: 'Aerospace & Defense' },
|
||||
{ value: 'Agriculture & Food', label: 'Agriculture & Food' },
|
||||
{
|
||||
value: 'Automotive & Transportation',
|
||||
label: 'Automotive & Transportation',
|
||||
},
|
||||
{ value: 'Chemicals & Materials', label: 'Chemicals & Materials' },
|
||||
{
|
||||
value: 'Construction & Built Environment',
|
||||
label: 'Construction & Built Environment',
|
||||
},
|
||||
{
|
||||
value: 'Consumer Packaged Goods (CPG)',
|
||||
label: 'Consumer Packaged Goods (CPG)',
|
||||
},
|
||||
{ value: 'Education', label: 'Education' },
|
||||
{ value: 'Entertainment', label: 'Entertainment' },
|
||||
{ value: 'Finance', label: 'Finance' },
|
||||
{ value: 'Government & Nonprofit', label: 'Government & Nonprofit' },
|
||||
{ value: 'Healthcare', label: 'Healthcare' },
|
||||
{ value: 'Hospitality & Tourism', label: 'Hospitality & Tourism' },
|
||||
{ value: 'Industrial & Energy', label: 'Industrial & Energy' },
|
||||
{ value: 'Legal & Compliance', label: 'Legal & Compliance' },
|
||||
{ value: 'Lifestyle & Leisure', label: 'Lifestyle & Leisure' },
|
||||
{ value: 'Logistics & Supply Chain', label: 'Logistics & Supply Chain' },
|
||||
{ value: 'Luxury & Fashion', label: 'Luxury & Fashion' },
|
||||
{ value: 'News & Media', label: 'News & Media' },
|
||||
{
|
||||
value: 'Professional Services & Agencies',
|
||||
label: 'Professional Services & Agencies',
|
||||
},
|
||||
{ value: 'Real Estate & PropTech', label: 'Real Estate & PropTech' },
|
||||
{ value: 'Retail & E-commerce', label: 'Retail & E-commerce' },
|
||||
{ value: 'Sports', label: 'Sports' },
|
||||
{ value: 'Technology', label: 'Technology' },
|
||||
{ value: 'Telecommunications', label: 'Telecommunications' },
|
||||
{ value: 'Other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const REFERRAL_SOURCE_OPTIONS = [
|
||||
{ value: 'google', label: 'Google' },
|
||||
{ value: 'reddit', label: 'Reddit' },
|
||||
{ value: 'twitter', label: 'Twitter/X' },
|
||||
{ value: 'linkedin', label: 'LinkedIn' },
|
||||
{ value: 'friend', label: 'Friend/Colleague' },
|
||||
{ value: 'blog', label: 'Blog/Article' },
|
||||
{ value: 'github', label: 'GitHub' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
export const USER_ROLE_OPTIONS = [
|
||||
{ value: 'founder', label: 'Founder/CEO' },
|
||||
{ value: 'product_manager', label: 'Product Manager' },
|
||||
{ value: 'engineering', label: 'Engineering' },
|
||||
{ value: 'support_lead', label: 'Support Lead' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'sales', label: 'Sales' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
@@ -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',
|
||||
|
||||
@@ -145,6 +145,7 @@ const openDelete = inbox => {
|
||||
<ChannelName
|
||||
:channel-type="inbox.channel_type"
|
||||
:medium="inbox.medium"
|
||||
:voice-enabled="inbox.voice_enabled"
|
||||
class="text-body-main text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAVoiceChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1240,6 +1256,12 @@ export default {
|
||||
>
|
||||
<ConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedTabKey === 'voice-configuration'"
|
||||
class="mx-6 max-w-4xl"
|
||||
>
|
||||
<VoiceConfigurationPage :inbox="inbox" />
|
||||
</div>
|
||||
<div v-if="selectedTabKey === 'csat'">
|
||||
<CustomerSatisfactionPage :inbox="inbox" />
|
||||
</div>
|
||||
|
||||
@@ -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]}`);
|
||||
|
||||
-18
@@ -208,24 +208,6 @@ export default {
|
||||
</NextButton>
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel">
|
||||
<SettingsFieldSection
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script>
|
||||
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;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<SettingsToggleSection
|
||||
v-model="voiceEnabled"
|
||||
:header="$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.LABEL')"
|
||||
:description="
|
||||
$t('INBOX_MGMT.VOICE_CONFIGURATION.ENABLE_VOICE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
|
||||
<div v-if="voiceEnabled && needsCredentials" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_MGMT.VOICE_CONFIGURATION.CREDENTIALS.DESCRIPTION') }}
|
||||
</p>
|
||||
<NextInput
|
||||
v-if="needsApiKeySid"
|
||||
v-model="apiKeySid"
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.LABEL')"
|
||||
:placeholder="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SID.PLACEHOLDER')"
|
||||
/>
|
||||
<NextInput
|
||||
v-model="apiKeySecret"
|
||||
type="password"
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.LABEL')"
|
||||
:placeholder="
|
||||
$t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="inbox.voice_enabled && inbox.voice_call_webhook_url">
|
||||
<SettingsFieldSection
|
||||
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
<SettingsFieldSection
|
||||
:label="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')
|
||||
"
|
||||
:help-text="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsFieldSection>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NextButton
|
||||
:disabled="isSubmitDisabled"
|
||||
:is-loading="isUpdating"
|
||||
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
|
||||
@click="updateVoiceSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ import UserBasicDetails from './UserBasicDetails.vue';
|
||||
import MessageSignature from './MessageSignature.vue';
|
||||
import FontSize from './FontSize.vue';
|
||||
import UserLanguageSelect from './UserLanguageSelect.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import HotKeyCard from './HotKeyCard.vue';
|
||||
import ChangePassword from './ChangePassword.vue';
|
||||
import NotificationPreferences from './NotificationPreferences.vue';
|
||||
@@ -43,13 +44,16 @@ export default {
|
||||
AccessToken,
|
||||
MfaSettingsCard,
|
||||
BaseSettingsHeader,
|
||||
SettingsToggleSection,
|
||||
},
|
||||
setup() {
|
||||
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
|
||||
const { uiSettings, isEditorHotKeyEnabled, updateUISettings } =
|
||||
useUISettings();
|
||||
const { currentFontSize, updateFontSize } = useFontSize();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
currentFontSize,
|
||||
updateFontSize,
|
||||
isEditorHotKeyEnabled,
|
||||
@@ -103,6 +107,14 @@ export default {
|
||||
isMfaEnabled() {
|
||||
return parseBoolean(window.chatwootConfig?.isMfaEnabled);
|
||||
},
|
||||
homeDashboardEnabled: {
|
||||
get() {
|
||||
return !!this.uiSettings?.home_dashboard_enabled;
|
||||
},
|
||||
set(value) {
|
||||
this.updateUISettings({ home_dashboard_enabled: value });
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.currentUserId) {
|
||||
@@ -248,6 +260,16 @@ export default {
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
|
||||
"
|
||||
/>
|
||||
<SettingsToggleSection
|
||||
v-model="homeDashboardEnabled"
|
||||
class="w-full"
|
||||
:header="
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.TITLE')
|
||||
"
|
||||
:description="
|
||||
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.HOME_DASHBOARD.NOTE')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</SectionLayout>
|
||||
<SectionLayout
|
||||
|
||||
+32
-1
@@ -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')"
|
||||
/>
|
||||
<Input
|
||||
v-if="!useBackupCodeToDisable"
|
||||
v-model="disableOtpCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.OTP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.DISABLE.OTP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="disableBackupCode"
|
||||
type="text"
|
||||
maxlength="8"
|
||||
:label="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE')"
|
||||
:placeholder="$t('MFA_SETTINGS.DISABLE.BACKUP_CODE_PLACEHOLDER')"
|
||||
/>
|
||||
<Button
|
||||
link
|
||||
sm
|
||||
type="button"
|
||||
:label="
|
||||
useBackupCodeToDisable
|
||||
? $t('MFA_SETTINGS.DISABLE.USE_OTP_CODE')
|
||||
: $t('MFA_SETTINGS.DISABLE.USE_BACKUP_CODE')
|
||||
"
|
||||
@click="toggleDisableMethod"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -132,8 +132,10 @@ const headers = computed(() => [
|
||||
</template>
|
||||
</BaseTable>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<p class="text-body-main text-n-slate-11">
|
||||
<div
|
||||
class="sticky bottom-0 py-4 px-8 -mx-8 z-20 flex items-center justify-between bg-n-surface-1 border-t border-n-weak"
|
||||
>
|
||||
<p class="text-body-main text-n-slate-11 mb-0">
|
||||
{{
|
||||
$t('TEAMS_SETTINGS.AGENTS.SELECTED_COUNT', {
|
||||
selected: selectedAgents.length,
|
||||
|
||||
@@ -88,7 +88,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
|
||||
@@ -103,7 +103,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full p-8 col-span-6 overflow-auto">
|
||||
<div class="h-full w-full px-8 pt-8 col-span-6 overflow-auto">
|
||||
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
|
||||
<PageHeader
|
||||
:header-title="headerTitle"
|
||||
|
||||
@@ -4,13 +4,15 @@ import { frontendURL } from '../helper/URLHelper';
|
||||
import dashboard from './dashboard/dashboard.routes';
|
||||
import store from 'dashboard/store';
|
||||
import { validateLoggedInRoutes } from '../helper/routeHelpers';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import AnalyticsHelper from '../helper/AnalyticsHelper';
|
||||
|
||||
const ONBOARDING_STEPS = ['account_details', 'enrichment'];
|
||||
const routes = [...dashboard.routes];
|
||||
|
||||
export const router = createRouter({ history: createWebHistory(), routes });
|
||||
|
||||
export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
export const validateAuthenticateRoutePermission = async (to, next) => {
|
||||
const { isLoggedIn, getCurrentUser: user } = store.getters;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
@@ -27,8 +29,27 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
return next(frontendURL('no-accounts'));
|
||||
}
|
||||
|
||||
const routeAccountId = Number(to.params?.accountId || accountId);
|
||||
const userAccount = accounts.find(a => a.id === routeAccountId);
|
||||
const isAdmin = userAccount?.role === 'administrator';
|
||||
const isActive = userAccount?.status === 'active';
|
||||
const needsOnboarding =
|
||||
ONBOARDING_STEPS.includes(userAccount?.onboarding_step) &&
|
||||
isAdmin &&
|
||||
isActive;
|
||||
|
||||
if (to.name === 'no_accounts' || !to.name) {
|
||||
return next(frontendURL(`accounts/${accountId}/dashboard`));
|
||||
const homeDashboardEnabled = !!user?.ui_settings?.home_dashboard_enabled;
|
||||
const landingTarget = homeDashboardEnabled ? 'home' : 'dashboard';
|
||||
const target = needsOnboarding ? 'onboarding' : landingTarget;
|
||||
return next(frontendURL(`accounts/${routeAccountId}/${target}`));
|
||||
}
|
||||
|
||||
if (needsOnboarding && !isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/onboarding`));
|
||||
}
|
||||
if (!needsOnboarding && isOnOnboardingView(to)) {
|
||||
return next(frontendURL(`accounts/${routeAccountId}/dashboard`));
|
||||
}
|
||||
|
||||
const nextRoute = validateLoggedInRoutes(to, store.getters.getCurrentUser);
|
||||
@@ -38,15 +59,14 @@ export const validateAuthenticateRoutePermission = (to, next) => {
|
||||
export const initalizeRouter = () => {
|
||||
const userAuthentication = store.dispatch('setUser');
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
AnalyticsHelper.page(to.name || '', {
|
||||
path: to.path,
|
||||
name: to.name,
|
||||
});
|
||||
|
||||
userAuthentication.then(() => {
|
||||
return validateAuthenticateRoutePermission(to, next, store);
|
||||
});
|
||||
await userAuthentication;
|
||||
await validateAuthenticateRoutePermission(to, next, store);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ vi.mock('../store', () => ({
|
||||
id: null,
|
||||
accounts: [],
|
||||
},
|
||||
'accounts/getAccount': () => ({}),
|
||||
},
|
||||
dispatch: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -60,14 +62,14 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
});
|
||||
|
||||
describe('when route is not accessible to current user', () => {
|
||||
it('should redirect to dashboard', () => {
|
||||
it('should redirect to dashboard', async () => {
|
||||
const to = {
|
||||
name: 'general_settings_index',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
validateAuthenticateRoutePermission(to, next);
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith('/app/accounts/1/dashboard');
|
||||
});
|
||||
@@ -90,14 +92,14 @@ describe('#validateAuthenticateRoutePermission', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should go to the intended route', () => {
|
||||
it('should go to the intended route', async () => {
|
||||
const to = {
|
||||
name: 'general_settings_index',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
};
|
||||
|
||||
validateAuthenticateRoutePermission(to, next);
|
||||
await validateAuthenticateRoutePermission(to, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
@@ -54,18 +54,19 @@ export const getters = {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
get: async ({ commit }) => {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
get: async ({ commit }, { silent } = {}) => {
|
||||
if (!silent) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
|
||||
}
|
||||
try {
|
||||
const response = await AccountAPI.get();
|
||||
commit(types.default.ADD_ACCOUNT, response.data);
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch (error) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, {
|
||||
isFetchingItem: false,
|
||||
});
|
||||
} catch {
|
||||
// silent failure
|
||||
} finally {
|
||||
if (!silent) {
|
||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
update: async ({ commit }, { options, ...updateObj }) => {
|
||||
|
||||
@@ -269,6 +269,20 @@ export const mutations = {
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.RESET_ONBOARDING](_state, accountId) {
|
||||
const accounts = _state.currentUser.accounts.map(account => {
|
||||
if (account.id === accountId) {
|
||||
const { onboarding_step, ...rest } = account;
|
||||
return rest;
|
||||
}
|
||||
return account;
|
||||
});
|
||||
|
||||
_state.currentUser = {
|
||||
..._state.currentUser,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
[types.CLEAR_USER](_state) {
|
||||
_state.currentUser = initialState.currentUser;
|
||||
},
|
||||
|
||||
@@ -46,8 +46,8 @@
|
||||
* 2. Nested properties in additional_attributes (browser_language, referer, etc.)
|
||||
* 3. Nested properties in custom_attributes (conversation_type, etc.)
|
||||
*/
|
||||
import jsonLogic from 'json-logic-js';
|
||||
import { coerceToDate } from '@chatwoot/utils';
|
||||
import jsonLogic from 'json-logic-js';
|
||||
|
||||
/**
|
||||
* Gets a value from a conversation based on the attribute key
|
||||
@@ -121,7 +121,8 @@ const resolveValue = candidate => {
|
||||
* @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
|
||||
*
|
||||
* This function handles various equality scenarios:
|
||||
* 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
|
||||
* 1. When both values are arrays (e.g. labels): matches if any filter value exists in the conversation array
|
||||
* (mirrors the backend SQL `tag_id IN (...)` OR semantics)
|
||||
* 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
|
||||
* 3. Otherwise: performs strict equality comparison
|
||||
*/
|
||||
@@ -131,8 +132,9 @@ const equalTo = (filterValue, conversationValue) => {
|
||||
if (filterValue === 'all') return true;
|
||||
|
||||
if (Array.isArray(conversationValue)) {
|
||||
// For array values like labels, check if any of the filter values exist in the array
|
||||
return filterValue.every(val => conversationValue.includes(val));
|
||||
// For array values like labels, match if any filter value is present.
|
||||
// Mirrors the backend SQL `tag_id IN (...)` (OR semantics).
|
||||
return filterValue.some(val => conversationValue.includes(val));
|
||||
}
|
||||
|
||||
if (!Array.isArray(conversationValue)) {
|
||||
|
||||
+34
@@ -416,6 +416,40 @@ describe('filterHelpers', () => {
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
// Multi-label equal_to uses OR semantics to mirror the backend SQL `tag_id IN (...)`:
|
||||
// a conversation matches if ANY of the filter labels is on it.
|
||||
it('should match conversation with equal_to operator when any of multiple filter labels is present', () => {
|
||||
const conversation = { labels: ['support'] };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'support', name: 'Support' },
|
||||
{ id: 'urgent', name: 'Urgent' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match conversation with equal_to operator when none of multiple filter labels is present', () => {
|
||||
const conversation = { labels: ['new'] };
|
||||
const filters = [
|
||||
{
|
||||
attribute_key: 'labels',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'support', name: 'Support' },
|
||||
{ id: 'urgent', name: 'Urgent' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
},
|
||||
];
|
||||
expect(matchesFilters(conversation, filters)).toBe(false);
|
||||
});
|
||||
|
||||
it('should match conversation with is_present operator for labels', () => {
|
||||
const conversation = { labels: ['support', 'urgent', 'new'] };
|
||||
const filters = [
|
||||
|
||||
@@ -57,4 +57,46 @@ describe('#mutations', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('#RESET_ONBOARDING', () => {
|
||||
it('removes onboarding_step from the targeted account', () => {
|
||||
const state = {
|
||||
currentUser: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
onboarding_step: 'account_details',
|
||||
role: 'administrator',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mutations[types.RESET_ONBOARDING](state, 1);
|
||||
expect(state.currentUser.accounts[0]).not.toHaveProperty(
|
||||
'onboarding_step'
|
||||
);
|
||||
expect(state.currentUser.accounts[0].role).toEqual('administrator');
|
||||
});
|
||||
|
||||
it('targets the route account, not currentUser.account_id', () => {
|
||||
const state = {
|
||||
currentUser: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
accounts: [
|
||||
{ id: 1, onboarding_step: 'account_details' },
|
||||
{ id: 2, onboarding_step: 'account_details' },
|
||||
],
|
||||
},
|
||||
};
|
||||
mutations[types.RESET_ONBOARDING](state, 2);
|
||||
expect(state.currentUser.accounts[0].onboarding_step).toEqual(
|
||||
'account_details'
|
||||
);
|
||||
expect(state.currentUser.accounts[1]).not.toHaveProperty(
|
||||
'onboarding_step'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ export default {
|
||||
SET_CURRENT_USER: 'SET_CURRENT_USER',
|
||||
SET_CURRENT_USER_AVAILABILITY: 'SET_CURRENT_USER_AVAILABILITY',
|
||||
SET_CURRENT_USER_AUTO_OFFLINE: 'SET_CURRENT_USER_AUTO_OFFLINE',
|
||||
RESET_ONBOARDING: 'RESET_ONBOARDING',
|
||||
SET_CURRENT_USER_UI_SETTINGS: 'SET_CURRENT_USER_UI_SETTINGS',
|
||||
SET_CURRENT_USER_UI_FLAGS: 'SET_CURRENT_USER_UI_FLAGS',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
@@ -59,8 +59,8 @@ export default {
|
||||
isALineChannel() {
|
||||
return this.channelType === INBOX_TYPES.LINE;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
voiceCallEnabled() {
|
||||
return isVoiceCallEnabled(this.inbox);
|
||||
},
|
||||
isAnEmailChannel() {
|
||||
return this.channelType === INBOX_TYPES.EMAIL;
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
WIDGET_BRAND_URL: widgetBrandURL,
|
||||
DISABLE_USER_PROFILE_UPDATE: disableUserProfileUpdate,
|
||||
DEPLOYMENT_ENV: deploymentEnv,
|
||||
ACTIVE_PLATFORM_BANNERS: activePlatformBanners,
|
||||
} = window.globalConfig || {};
|
||||
|
||||
const state = {
|
||||
@@ -49,6 +50,7 @@ const state = {
|
||||
termsURL,
|
||||
widgetBrandURL,
|
||||
isEnterprise: parseBoolean(isEnterprise),
|
||||
activePlatformBanners: activePlatformBanners || [],
|
||||
};
|
||||
|
||||
export const getters = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class AgentBots::WebhookJob < WebhookJob
|
||||
queue_as :high
|
||||
retry_on RestClient::TooManyRequests, RestClient::InternalServerError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||
retry_on Webhooks::Trigger::RetryableError, wait: 3.seconds, attempts: 3 do |job, error|
|
||||
url, payload, webhook_type = job.arguments
|
||||
kwargs = job.arguments.last.is_a?(Hash) ? job.arguments.last : {}
|
||||
Webhooks::Trigger.new(url, payload, webhook_type || :agent_bot_webhook, secret: kwargs[:secret],
|
||||
@@ -9,7 +9,7 @@ class AgentBots::WebhookJob < WebhookJob
|
||||
|
||||
def perform(url, payload, webhook_type = :agent_bot_webhook, secret: nil, delivery_id: nil)
|
||||
super(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
|
||||
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
|
||||
rescue Webhooks::Trigger::RetryableError => e
|
||||
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
|
||||
raise
|
||||
end
|
||||
|
||||
+17
-6
@@ -23,13 +23,24 @@ class HookJob < MutexApplicationJob
|
||||
private
|
||||
|
||||
def process_slack_integration(hook, event_name, event_data)
|
||||
return unless ['message.created'].include?(event_name)
|
||||
|
||||
message = event_data[:message]
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
|
||||
case event_name
|
||||
when 'message.created'
|
||||
if message.attachments.blank?
|
||||
::SendOnSlackJob.perform_later(message, hook)
|
||||
else
|
||||
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
|
||||
end
|
||||
when 'message.updated'
|
||||
# Only interactive bot messages store responses via content_attributes (submitted_values / submitted_email).
|
||||
# Skip other content types to avoid unnecessary job enqueues on every message update.
|
||||
return unless message.content_type.in?(Integrations::Slack::UpdateSlackMessageService::SUPPORTED_CONTENT_TYPES)
|
||||
# Guard against redundant Slack updates when unrelated attributes change (e.g. status)
|
||||
# while submitted_values is already present on the message.
|
||||
return unless event_data[:previous_changes]&.key?('content_attributes')
|
||||
|
||||
::UpdateSlackMessageJob.perform_later(message, hook)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class UpdateSlackMessageJob < MutexApplicationJob
|
||||
queue_as :medium
|
||||
retry_on LockAcquisitionError, wait: 1.second, attempts: 8
|
||||
|
||||
def perform(message, hook)
|
||||
key = format(::Redis::Alfred::SLACK_MESSAGE_MUTEX, conversation_id: message.conversation_id, reference_id: hook.reference_id)
|
||||
with_lock(key) do
|
||||
Integrations::Slack::UpdateSlackMessageService.new(message: message, hook: hook).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,9 @@
|
||||
class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
# Retry budget (19 × 2s = 38s) must exceed the 30s lock TTL set in `perform`, otherwise
|
||||
# a webhook that arrives just after the lock is acquired can exhaust retries before the
|
||||
# holder finishes and silently drop its message.
|
||||
retry_on LockAcquisitionError, wait: 2.seconds, attempts: 20
|
||||
|
||||
def perform(params = {})
|
||||
channel = find_channel_from_whatsapp_business_payload(params)
|
||||
@@ -9,6 +13,20 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
return
|
||||
end
|
||||
|
||||
sender_id = contact_sender_id(params)
|
||||
return process_events(channel, params) if sender_id.blank?
|
||||
|
||||
# Album uploads arrive as separate concurrent webhooks. Serialize per (inbox, contact)
|
||||
# so the first webhook creates the conversation and the rest append to it.
|
||||
# 30s TTL covers the attachment download + transaction — the default 1s expires
|
||||
# mid-processing and lets a concurrent webhook re-acquire before the first commit.
|
||||
key = format(::Redis::Alfred::WHATSAPP_MESSAGE_MUTEX, inbox_id: channel.inbox.id, sender_id: sender_id)
|
||||
with_lock(key, 30.seconds) do
|
||||
process_events(channel, params)
|
||||
end
|
||||
end
|
||||
|
||||
def process_events(channel, params)
|
||||
if message_echo_event?(params)
|
||||
handle_message_echo(channel, params)
|
||||
else
|
||||
@@ -69,6 +87,16 @@ class Webhooks::WhatsappEventsJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
# Echo payloads reverse the fields — `from` is the business number and `to` is the contact.
|
||||
# Returns nil for status-only webhooks so they bypass the lock.
|
||||
def contact_sender_id(params)
|
||||
value = params.dig(:entry, 0, :changes, 0, :value) || params
|
||||
message = (value[:messages] || value[:message_echoes])&.first
|
||||
return if message.blank?
|
||||
|
||||
message[:to] || message[:from]
|
||||
end
|
||||
|
||||
def channel_is_inactive?(channel)
|
||||
return true if channel.blank?
|
||||
return true if channel.reauthorization_required?
|
||||
|
||||
@@ -43,7 +43,7 @@ class HookListener < BaseListener
|
||||
next if hook.inbox.present? && hook.inbox != message.inbox
|
||||
next unless supported_hook_event?(hook, event.name)
|
||||
|
||||
HookJob.perform_later(hook, event.name, message: message)
|
||||
HookJob.perform_later(hook, event.name, message: message, previous_changes: event.data[:previous_changes])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +59,7 @@ class HookListener < BaseListener
|
||||
return false if hook.disabled?
|
||||
|
||||
supported_events_map = {
|
||||
'slack' => ['message.created'],
|
||||
'slack' => ['message.created', 'message.updated'],
|
||||
'dialogflow' => ['message.created', 'message.updated'],
|
||||
'google_translate' => ['message.created'],
|
||||
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
|
||||
|
||||
@@ -157,6 +157,14 @@ class Account < ApplicationRecord
|
||||
ISO_639.find(account_locale)&.english_name&.downcase || 'english'
|
||||
end
|
||||
|
||||
def onboarding_step
|
||||
step = custom_attributes['onboarding_step']
|
||||
return nil if step.blank?
|
||||
|
||||
enrichment_key = format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: id)
|
||||
Redis::Alfred.exists?(enrichment_key) ? 'enrichment' : step
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def notify_creation
|
||||
|
||||
@@ -32,6 +32,7 @@ class AgentBot < ApplicationRecord
|
||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||
has_many :inboxes, through: :agent_bot_inboxes
|
||||
has_many :messages, as: :sender, dependent: :nullify
|
||||
has_many :platform_app_permissibles, as: :permissible, dependent: :destroy
|
||||
has_many :assigned_conversations, class_name: 'Conversation',
|
||||
foreign_key: :assignee_agent_bot_id,
|
||||
dependent: :nullify,
|
||||
|
||||
@@ -35,7 +35,7 @@ class AutomationRule < ApplicationRecord
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
def conditions_attributes
|
||||
%w[content email country_code status message_type browser_language assignee_id team_id referer city company inbox_id
|
||||
%w[content email country_code status message_type browser_language assignee_id team_id referer city company_name inbox_id
|
||||
mail_subject phone_number priority conversation_language labels private_note]
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_sid :string not null
|
||||
# api_key_secret :string
|
||||
# api_key_sid :string
|
||||
# auth_token :string not null
|
||||
# content_templates :jsonb
|
||||
@@ -11,6 +12,8 @@
|
||||
# medium :integer default("sms")
|
||||
# messaging_service_sid :string
|
||||
# phone_number :string
|
||||
# twiml_app_sid :string
|
||||
# voice_enabled :boolean default(FALSE), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
@@ -58,8 +61,6 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
client.messages.create(**params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
if api_key_sid.present?
|
||||
Twilio::REST::Client.new(api_key_sid, auth_token, account_sid)
|
||||
@@ -68,6 +69,8 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def send_message_from
|
||||
if messaging_service_sid?
|
||||
{ messaging_service_sid: messaging_service_sid }
|
||||
@@ -76,3 +79,5 @@ class Channel::TwilioSms < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Channel::TwilioSms.prepend_mod_with('Channel::TwilioSms')
|
||||
|
||||
@@ -19,7 +19,7 @@ module SortHandler
|
||||
end
|
||||
|
||||
def sort_on_waiting_since(sort_direction = :asc)
|
||||
order(generate_sql_query("waiting_since #{sort_direction.to_s.upcase} NULLS LAST, created_at ASC"))
|
||||
order(generate_sql_query("(waiting_since IS NULL), waiting_since #{sort_direction.to_s.upcase}, created_at ASC"))
|
||||
end
|
||||
|
||||
def last_messaged_conversations
|
||||
|
||||
@@ -25,7 +25,7 @@ class CustomAttributeDefinition < ApplicationRecord
|
||||
STANDARD_ATTRIBUTES = {
|
||||
:conversation => %w[status priority assignee_id inbox_id team_id display_id campaign_id labels browser_language country_code referer created_at
|
||||
last_activity_at],
|
||||
:contact => %w[name email phone_number identifier country_code city created_at last_activity_at referer blocked]
|
||||
:contact => %w[name email phone_number identifier country_code city company_name created_at last_activity_at referer blocked]
|
||||
}.freeze
|
||||
|
||||
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: platform_banners
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active :boolean default(TRUE)
|
||||
# banner_message :text not null
|
||||
# banner_type :integer default("info"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class PlatformBanner < ApplicationRecord
|
||||
enum :banner_type, { info: 0, warning: 1, error: 2 }
|
||||
|
||||
validates :banner_message, presence: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
end
|
||||
@@ -75,6 +75,7 @@ class User < ApplicationRecord
|
||||
# work because :validatable in devise overrides this.
|
||||
# validates_uniqueness_of :email, scope: :account_id
|
||||
|
||||
validates :name, presence: true
|
||||
validates :email, presence: true
|
||||
|
||||
serialize :otp_backup_codes, type: Array
|
||||
|
||||
@@ -61,7 +61,7 @@ class DataImport::ContactManager
|
||||
def update_contact_attributes(params, contact)
|
||||
contact.name = params[:name] if params[:name].present?
|
||||
contact.additional_attributes ||= {}
|
||||
contact.additional_attributes[:company] = params[:company] if params[:company].present?
|
||||
contact.additional_attributes[:company_name] = params[:company_name] if params[:company_name].present?
|
||||
contact.additional_attributes[:city] = params[:city] if params[:city].present?
|
||||
contact.assign_attributes(custom_attributes: contact.custom_attributes.merge(params.except(:identifier, :email, :name, :phone_number)))
|
||||
end
|
||||
|
||||
@@ -50,6 +50,8 @@ class Messages::MentionService
|
||||
|
||||
def generate_notifications_for_mentions(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
next if self_mention?(user_id)
|
||||
|
||||
NotificationBuilder.new(
|
||||
notification_type: 'conversation_mention',
|
||||
user: User.find(user_id),
|
||||
@@ -60,6 +62,10 @@ class Messages::MentionService
|
||||
end
|
||||
end
|
||||
|
||||
def self_mention?(user_id)
|
||||
message.sender_type == 'User' && user_id.to_i == message.sender_id
|
||||
end
|
||||
|
||||
def add_mentioned_users_as_participants(validated_mentioned_ids)
|
||||
validated_mentioned_ids.each do |user_id|
|
||||
message.conversation.conversation_participants.find_or_create_by(user_id: user_id)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
class Whatsapp::LiquidTemplateProcessorService
|
||||
LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/
|
||||
|
||||
module JsonEscapeFilter
|
||||
def json_escape(input)
|
||||
input.to_s.to_json[1..-2]
|
||||
end
|
||||
end
|
||||
|
||||
pattr_initialize [:campaign!, :contact!]
|
||||
|
||||
def process_template_params(template_params)
|
||||
return template_params if template_params.blank?
|
||||
|
||||
template_params_copy = template_params.deep_dup
|
||||
processed_params = template_params_copy['processed_params']
|
||||
|
||||
return template_params_copy if processed_params.blank?
|
||||
|
||||
rendered_params = render_liquid(processed_params)
|
||||
return nil if blank_render?(processed_params, rendered_params)
|
||||
|
||||
template_params_copy.merge('processed_params' => rendered_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_liquid(processed_params)
|
||||
raw = processed_params.to_json
|
||||
rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" }
|
||||
rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter])
|
||||
JSON.parse(rendered)
|
||||
rescue Liquid::Error, JSON::ParserError
|
||||
processed_params
|
||||
end
|
||||
|
||||
def drops
|
||||
{
|
||||
'contact' => ContactDrop.new(contact),
|
||||
'agent' => UserDrop.new(campaign.sender),
|
||||
'inbox' => InboxDrop.new(campaign.inbox),
|
||||
'account' => AccountDrop.new(campaign.account)
|
||||
}
|
||||
end
|
||||
|
||||
def blank_render?(original, rendered)
|
||||
case original
|
||||
when Hash then blank_render_in_hash?(original, rendered)
|
||||
when Array then blank_render_in_array?(original, rendered)
|
||||
when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank?
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def blank_render_in_hash?(original, rendered)
|
||||
return false unless rendered.is_a?(Hash)
|
||||
|
||||
original.any? { |key, value| blank_render?(value, rendered[key]) }
|
||||
end
|
||||
|
||||
def blank_render_in_array?(original, rendered)
|
||||
return false unless rendered.is_a?(Array)
|
||||
|
||||
original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) }
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user