Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0893ff3548 | ||
|
|
17a6fa6c1d |
@@ -1,65 +0,0 @@
|
||||
---
|
||||
:position: before
|
||||
:position_in_additional_file_patterns: before
|
||||
:position_in_class: before
|
||||
:position_in_factory: before
|
||||
:position_in_fixture: before
|
||||
:position_in_routes: before
|
||||
:position_in_serializer: before
|
||||
:position_in_test: before
|
||||
:classified_sort: true
|
||||
:exclude_controllers: true
|
||||
:exclude_factories: true
|
||||
:exclude_fixtures: true
|
||||
:exclude_helpers: true
|
||||
:exclude_scaffolds: true
|
||||
:exclude_serializers: true
|
||||
:exclude_sti_subclasses: false
|
||||
:exclude_tests: true
|
||||
:force: false
|
||||
:format_markdown: false
|
||||
:format_rdoc: false
|
||||
:format_yard: false
|
||||
:frozen: false
|
||||
:grouped_polymorphic: false
|
||||
:ignore_model_sub_dir: false
|
||||
:ignore_unknown_models: false
|
||||
:include_version: false
|
||||
:show_check_constraints: false
|
||||
:show_complete_foreign_keys: false
|
||||
:show_foreign_keys: true
|
||||
:show_indexes: true
|
||||
:show_indexes_include: false
|
||||
:simple_indexes: false
|
||||
:sort: false
|
||||
:timestamp: false
|
||||
:trace: false
|
||||
:with_comment: true
|
||||
:with_column_comments: true
|
||||
:with_table_comments: true
|
||||
:position_of_column_comment: :with_name
|
||||
:active_admin: false
|
||||
:command:
|
||||
:debug: false
|
||||
:hide_default_column_types: json,jsonb,hstore
|
||||
:hide_limit_column_types: integer,bigint,boolean
|
||||
:timestamp_columns:
|
||||
- created_at
|
||||
- updated_at
|
||||
:ignore_columns:
|
||||
:ignore_routes:
|
||||
:models: true
|
||||
:routes: false
|
||||
:skip_on_db_migrate: false
|
||||
:target_action: :do_annotations
|
||||
:wrapper:
|
||||
:wrapper_close:
|
||||
:wrapper_open:
|
||||
:classes_default_to_s: []
|
||||
:additional_file_patterns: []
|
||||
:model_dir:
|
||||
- app/models
|
||||
- enterprise/app/models
|
||||
:require: []
|
||||
:root_dir:
|
||||
- ''
|
||||
@@ -1,9 +1,3 @@
|
||||
---
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
|
||||
# storage setup uses local/cloud storage with optional direct uploads to the
|
||||
# storage provider rather than Rails proxy mode. Revisit if we enable
|
||||
# rails_storage_proxy or other app-served Active Storage proxy routes.
|
||||
- CVE-2026-33658
|
||||
|
||||
-256
@@ -1,256 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'airbnb-base/legacy',
|
||||
'prettier',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vitest-globals/recommended',
|
||||
// use recommended-legacy when upgrading the plugin to v4
|
||||
'plugin:@intlify/vue-i18n/recommended',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.spec.{j,t}s?(x)'],
|
||||
env: {
|
||||
'vitest-globals/env': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.story.vue'],
|
||||
rules: {
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: ['Variant', 'Story'],
|
||||
},
|
||||
],
|
||||
// Story files can have static strings, it doesn't need to handle i18n always.
|
||||
'vue/no-bare-strings-in-template': 'off',
|
||||
'no-console': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
plugins: ['html', 'prettier'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'prettier/prettier': ['error'],
|
||||
camelcase: 'off',
|
||||
'no-param-reassign': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'import/prefer-default-export': 'off',
|
||||
'import/no-named-as-default': 'off',
|
||||
'jsx-a11y/no-static-element-interactions': 'off',
|
||||
'jsx-a11y/click-events-have-key-events': 'off',
|
||||
'jsx-a11y/label-has-associated-control': 'off',
|
||||
'jsx-a11y/label-has-for': 'off',
|
||||
'jsx-a11y/anchor-is-valid': 'off',
|
||||
'import/no-unresolved': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/next-tick-style': ['error', 'callback'],
|
||||
'vue/block-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['script', 'template', 'style'],
|
||||
},
|
||||
],
|
||||
'vue/component-name-in-template-casing': [
|
||||
'error',
|
||||
'PascalCase',
|
||||
{
|
||||
registeredComponentsOnly: true,
|
||||
},
|
||||
],
|
||||
'vue/component-options-name-casing': ['error', 'PascalCase'],
|
||||
'vue/custom-event-name-casing': ['error', 'camelCase'],
|
||||
'vue/define-emits-declaration': ['error'],
|
||||
'vue/define-macros-order': [
|
||||
'error',
|
||||
{
|
||||
order: ['defineProps', 'defineEmits'],
|
||||
defineExposeLast: false,
|
||||
},
|
||||
],
|
||||
'vue/define-props-declaration': ['error', 'runtime'],
|
||||
'vue/match-component-import-name': ['error'],
|
||||
'vue/no-bare-strings-in-template': [
|
||||
'error',
|
||||
{
|
||||
allowlist: [
|
||||
'(',
|
||||
')',
|
||||
',',
|
||||
'.',
|
||||
'&',
|
||||
'+',
|
||||
'-',
|
||||
'=',
|
||||
'*',
|
||||
'/',
|
||||
'#',
|
||||
'%',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
'[',
|
||||
']',
|
||||
'{',
|
||||
'}',
|
||||
'<',
|
||||
'>',
|
||||
'⌘',
|
||||
'📄',
|
||||
'🎉',
|
||||
'🚀',
|
||||
'💬',
|
||||
'👥',
|
||||
'📥',
|
||||
'🔖',
|
||||
'❌',
|
||||
'✅',
|
||||
'\u00b7',
|
||||
'\u2022',
|
||||
'\u2010',
|
||||
'\u2013',
|
||||
'\u2014',
|
||||
'\u2212',
|
||||
'|',
|
||||
],
|
||||
attributes: {
|
||||
'/.+/': [
|
||||
'title',
|
||||
'aria-label',
|
||||
'aria-placeholder',
|
||||
'aria-roledescription',
|
||||
'aria-valuetext',
|
||||
],
|
||||
input: ['placeholder'],
|
||||
},
|
||||
directives: ['v-text'],
|
||||
},
|
||||
],
|
||||
'vue/no-empty-component-block': 'error',
|
||||
'vue/no-multiple-objects-in-class': 'error',
|
||||
'vue/no-root-v-if': 'warn',
|
||||
'vue/no-static-inline-styles': [
|
||||
'error',
|
||||
{
|
||||
allowBinding: false,
|
||||
},
|
||||
],
|
||||
'vue/no-template-target-blank': [
|
||||
'error',
|
||||
{
|
||||
allowReferrer: false,
|
||||
enforceDynamicLinks: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-required-prop-with-default': [
|
||||
'error',
|
||||
{
|
||||
autofix: false,
|
||||
},
|
||||
],
|
||||
'vue/no-this-in-before-route-enter': 'error',
|
||||
'vue/no-undef-components': [
|
||||
'error',
|
||||
{
|
||||
ignorePatterns: [
|
||||
'^woot-',
|
||||
'^fluent-',
|
||||
'^multiselect',
|
||||
'^router-link',
|
||||
'^router-view',
|
||||
'^ninja-keys',
|
||||
'^FormulateForm',
|
||||
'^FormulateInput',
|
||||
'^highlightjs',
|
||||
],
|
||||
},
|
||||
],
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/prefer-true-attribute-shorthand': 'error',
|
||||
'vue/no-useless-v-bind': [
|
||||
'error',
|
||||
{
|
||||
ignoreIncludesComment: false,
|
||||
ignoreStringEscape: false,
|
||||
},
|
||||
],
|
||||
'vue/no-v-text': 'error',
|
||||
'vue/padding-line-between-blocks': ['error', 'always'],
|
||||
'vue/prefer-separate-static-class': 'error',
|
||||
'vue/require-explicit-slots': 'error',
|
||||
'vue/require-macro-variable-name': [
|
||||
'error',
|
||||
{
|
||||
defineProps: 'props',
|
||||
defineEmits: 'emit',
|
||||
defineSlots: 'slots',
|
||||
useSlots: 'slots',
|
||||
useAttrs: 'attrs',
|
||||
},
|
||||
],
|
||||
'vue/no-unused-properties': [
|
||||
'error',
|
||||
{
|
||||
groups: ['props'],
|
||||
deepData: false,
|
||||
ignorePublicMembers: false,
|
||||
unreferencedOptions: [],
|
||||
},
|
||||
],
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
singleline: {
|
||||
max: 20,
|
||||
},
|
||||
multiline: {
|
||||
max: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
html: {
|
||||
void: 'always',
|
||||
normal: 'always',
|
||||
component: 'always',
|
||||
},
|
||||
svg: 'always',
|
||||
math: 'always',
|
||||
},
|
||||
],
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/component-definition-name-casing': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error',
|
||||
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
|
||||
'@intlify/vue-i18n/no-unused-keys': [
|
||||
'warn',
|
||||
{
|
||||
extensions: ['.js', '.vue'],
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'vue-i18n': {
|
||||
localeDir: './app/javascript/*/i18n/**.json',
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
globals: {
|
||||
bus: true,
|
||||
vi: true,
|
||||
},
|
||||
};
|
||||
+2
-2
@@ -166,7 +166,7 @@ GEM
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.22)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
@@ -465,7 +465,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.19.2)
|
||||
json (2.18.1)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.12.1
|
||||
4.11.2
|
||||
|
||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save! if @contact.changed?
|
||||
@contact.save!
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
@agent_bot.reload
|
||||
end
|
||||
|
||||
def reset_secret
|
||||
@agent_bot.reset_secret!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_bot
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -116,8 +116,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
|
||||
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
|
||||
# Always update immediately if there are unread messages to maintain accurate read/unread state.
|
||||
# Visiting a conversation should clear any unread inbox notifications for this conversation.
|
||||
Notification::MarkConversationReadService.new(user: Current.user, account: Current.account, conversation: @conversation).perform
|
||||
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
|
||||
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -66,17 +65,28 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def reset_secret
|
||||
return head :not_found unless @inbox.api?
|
||||
|
||||
@inbox.channel.reset_secret!
|
||||
end
|
||||
|
||||
def destroy
|
||||
::DeleteObjectJob.perform_later(@inbox, Current.user, request.ip) if @inbox.present?
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -88,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -184,6 +200,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
return unless @hook&.access_token
|
||||
|
||||
begin
|
||||
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
|
||||
linear_client = Linear.new(@hook.access_token)
|
||||
linear_client.revoke_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||
|
||||
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -43,15 +43,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
return unless conversation.nil?
|
||||
|
||||
@conversation = create_conversation
|
||||
apply_labels if permitted_params[:labels].present?
|
||||
end
|
||||
|
||||
def apply_labels
|
||||
valid_labels = inbox.account.labels.where(title: permitted_params[:labels]).pluck(:title)
|
||||
@conversation.update_labels(valid_labels) if valid_labels.present?
|
||||
@conversation = create_conversation if conversation.nil?
|
||||
end
|
||||
|
||||
def message_finder_params
|
||||
@@ -72,14 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
# custom_attributes and labels are applied when a new conversation is created alongside the first message
|
||||
params.permit(
|
||||
:id, :before, :after, :website_token,
|
||||
contact: [:name, :email],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id, :reply_to],
|
||||
custom_attributes: {},
|
||||
labels: []
|
||||
)
|
||||
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
|
||||
end
|
||||
|
||||
def set_message
|
||||
|
||||
@@ -10,12 +10,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
private
|
||||
|
||||
def sign_in_user
|
||||
# Capture before skip_confirmation! sets confirmed_at, which would
|
||||
# make oauth_user_needs_password_reset? return false and skip the
|
||||
# password reset for persisted unconfirmed users.
|
||||
needs_password_reset = oauth_user_needs_password_reset?
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -25,10 +20,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
# See comment in sign_in_user for why this is captured before skip_confirmation!
|
||||
needs_password_reset = oauth_user_needs_password_reset?
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
set_random_password_if_oauth_user if needs_password_reset
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
@@ -45,7 +37,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
create_account_for_user
|
||||
set_random_password_if_oauth_user
|
||||
token = @resource.send(:set_reset_password_token)
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}"
|
||||
@@ -90,15 +81,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image'])
|
||||
end
|
||||
|
||||
def oauth_user_needs_password_reset?
|
||||
@resource.present? && (@resource.new_record? || !@resource.confirmed?)
|
||||
end
|
||||
|
||||
def set_random_password_if_oauth_user
|
||||
# Password must satisfy secure_password requirements (uppercase, lowercase, number, special char)
|
||||
@resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted?
|
||||
end
|
||||
|
||||
def default_devise_mapping
|
||||
'user'
|
||||
end
|
||||
|
||||
@@ -2,8 +2,6 @@ class Linear::CallbacksController < ApplicationController
|
||||
include Linear::IntegrationHelper
|
||||
|
||||
def show
|
||||
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/linear/callback"
|
||||
@@ -12,7 +10,7 @@ class Linear::CallbacksController < ApplicationController
|
||||
handle_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to safe_linear_redirect_uri
|
||||
redirect_to linear_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
@@ -33,19 +31,22 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def handle_response
|
||||
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
|
||||
|
||||
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
|
||||
hook.assign_attributes(
|
||||
hook = account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
settings: merged_integration_settings(hook.settings)
|
||||
app_id: 'linear',
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
)
|
||||
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
|
||||
hook.save!
|
||||
redirect_to linear_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to safe_linear_redirect_uri
|
||||
redirect_to linear_redirect_uri
|
||||
end
|
||||
|
||||
def account
|
||||
@@ -53,47 +54,19 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def account_id
|
||||
return @account_id if instance_variable_defined?(:@account_id)
|
||||
return unless params[:state]
|
||||
|
||||
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
|
||||
verify_linear_token(params[:state])
|
||||
end
|
||||
|
||||
def linear_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
||||
end
|
||||
|
||||
def safe_linear_redirect_uri
|
||||
return base_url if account_id.blank?
|
||||
|
||||
linear_redirect_uri
|
||||
rescue StandardError
|
||||
base_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def integration_settings
|
||||
{
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
expires_on: expires_on,
|
||||
scope: parsed_body['scope'],
|
||||
refresh_token: parsed_body['refresh_token']
|
||||
}.compact
|
||||
end
|
||||
|
||||
def merged_integration_settings(existing_settings)
|
||||
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
|
||||
end
|
||||
|
||||
def expires_on
|
||||
return if parsed_body['expires_in'].blank?
|
||||
|
||||
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
|
||||
before_action :set_account
|
||||
before_action :validate_account_permissible
|
||||
before_action :validate_feature_flag
|
||||
before_action :validate_params
|
||||
|
||||
def create
|
||||
results = migrate_email_channels
|
||||
render json: { results: results }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def validate_account_permissible
|
||||
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
|
||||
|
||||
render json: { error: 'Non permissible resource' }, status: :unauthorized
|
||||
end
|
||||
|
||||
def validate_feature_flag
|
||||
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
|
||||
|
||||
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
|
||||
end
|
||||
|
||||
def validate_params
|
||||
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
|
||||
|
||||
return unless migration_params.size > MAX_MIGRATIONS
|
||||
|
||||
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def migrate_email_channels
|
||||
migration_params.map { |entry| migrate_single(entry) }
|
||||
end
|
||||
|
||||
MAX_MIGRATIONS = 25
|
||||
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
|
||||
|
||||
def migrate_single(entry)
|
||||
validate_provider!(entry[:provider])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel(entry)
|
||||
inbox = create_inbox(channel, entry)
|
||||
|
||||
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
|
||||
end
|
||||
rescue StandardError => e
|
||||
{ email: entry[:email], status: 'error', message: e.message }
|
||||
end
|
||||
|
||||
def create_channel(entry)
|
||||
Channel::Email.create!(
|
||||
account_id: @account.id,
|
||||
email: entry[:email],
|
||||
provider: entry[:provider],
|
||||
provider_config: entry[:provider_config]&.to_h,
|
||||
imap_enabled: entry.fetch(:imap_enabled, true),
|
||||
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
|
||||
imap_port: entry[:imap_port] || 993,
|
||||
imap_login: entry[:imap_login] || entry[:email],
|
||||
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
|
||||
)
|
||||
end
|
||||
|
||||
def create_inbox(channel, entry)
|
||||
@account.inboxes.create!(
|
||||
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def validate_provider!(provider)
|
||||
return if SUPPORTED_PROVIDERS.include?(provider)
|
||||
|
||||
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
|
||||
end
|
||||
|
||||
def default_imap_address(provider)
|
||||
case provider
|
||||
when 'google' then 'imap.gmail.com'
|
||||
when 'microsoft' then 'outlook.office365.com'
|
||||
else ''
|
||||
end
|
||||
end
|
||||
|
||||
def migration_params
|
||||
params.permit(migrations: [
|
||||
:email, :provider, :inbox_name,
|
||||
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
|
||||
{ provider_config: {} }
|
||||
])[:migrations]
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,11 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, except: [:index, :show, :tracking_pixel]
|
||||
before_action :set_article, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@search_query = list_params[:query]
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
|
||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||
@@ -75,9 +73,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
|
||||
permitted[:query] = permitted[:query].to_s.strip.presence
|
||||
end
|
||||
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :set_category, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show]
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
before_action :portal
|
||||
before_action :ensure_portal_feature_enabled
|
||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||
layout 'portal'
|
||||
|
||||
def show
|
||||
@@ -25,7 +24,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
||||
def redirect_to_portal_with_locale
|
||||
return if params[:locale].present?
|
||||
|
||||
portal
|
||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,11 +18,4 @@ class PublicController < ActionController::Base
|
||||
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
|
||||
}, status: :unauthorized and return
|
||||
end
|
||||
|
||||
def ensure_portal_feature_enabled
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
return if @portal.account.feature_enabled?('help_center')
|
||||
|
||||
render 'public/api/v1/portals/not_active', status: :payment_required
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,23 +77,13 @@ class WidgetsController < ActionController::Base
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
|
||||
if @web_widget.allowed_domains.blank?
|
||||
response.headers.delete('X-Frame-Options')
|
||||
else
|
||||
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
||||
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
||||
end
|
||||
end
|
||||
|
||||
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
|
||||
# which cannot match any domain in frame-ancestors. When the per-inbox flag
|
||||
# is enabled, skip frame-ancestors for these requests.
|
||||
def embedded_from_non_web_origin?
|
||||
return false unless @web_widget.allow_mobile_webview?
|
||||
|
||||
origin = request.headers['Origin']
|
||||
origin.blank? || origin == 'null' || origin&.start_with?('file://')
|
||||
end
|
||||
end
|
||||
|
||||
WidgetsController.prepend_mod_with('WidgetsController')
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -98,9 +98,7 @@ export default {
|
||||
mql.onchange = e => setColorTheme(e.matches);
|
||||
},
|
||||
setLocale(locale) {
|
||||
if (locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
}
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
async initializeAccount() {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
|
||||
@@ -13,7 +13,6 @@ class ApiClient {
|
||||
return `${this.baseUrl()}/${this.resource}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get accountIdFromRoute() {
|
||||
const isInsideAccountScopedURLs =
|
||||
window.location.pathname.includes('/app/accounts');
|
||||
|
||||
@@ -8,7 +8,6 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
this.dataManager = new DataManager(this.accountIdFromRoute);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
throw new Error('cacheModelName is not defined');
|
||||
}
|
||||
@@ -25,12 +24,10 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data.payload;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: { payload: dataToParse } };
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ class AgentBotsAPI extends ApiClient {
|
||||
resetAccessToken(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_access_token`);
|
||||
}
|
||||
|
||||
resetSecret(botId) {
|
||||
return axios.post(`${this.url}/${botId}/reset_secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new AgentBotsAPI();
|
||||
|
||||
@@ -31,12 +31,6 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
@@ -7,7 +7,6 @@ class ChangelogApi extends ApiClient {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
return axios.get(CHANGELOG_API_URL);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ class VoiceAPI extends ApiClient {
|
||||
super('voice', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
initiateCall(contactId, inboxId) {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(requestURL);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
filter(page = 1, sortAttr = 'name', queryPayload) {
|
||||
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
|
||||
return axios.post(requestURL, queryPayload);
|
||||
|
||||
@@ -9,10 +9,6 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -6,7 +6,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'inbox';
|
||||
}
|
||||
@@ -48,10 +47,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
resetSecret(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/reset_secret`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -5,7 +5,6 @@ class LabelsAPI extends CacheEnabledApiClient {
|
||||
super('labels', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'label';
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
params: {
|
||||
|
||||
@@ -7,17 +7,14 @@ export class TeamsAPI extends CacheEnabledApiClient {
|
||||
super('teams', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get cacheModelName() {
|
||||
return 'team';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
extractDataFromResponse(response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
marshallData(dataToParse) {
|
||||
return { data: dataToParse };
|
||||
}
|
||||
|
||||
@@ -106,10 +106,6 @@ select {
|
||||
&[disabled] {
|
||||
@apply field-disabled;
|
||||
}
|
||||
|
||||
option:not(:disabled) {
|
||||
@apply bg-n-solid-2 text-n-slate-12;
|
||||
}
|
||||
}
|
||||
|
||||
// Textarea
|
||||
|
||||
+3
-3
@@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', {
|
||||
|
||||
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
|
||||
type: Number,
|
||||
default: null,
|
||||
default: 10,
|
||||
});
|
||||
|
||||
// Duration limits: 1 minute to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 1;
|
||||
// Duration limits: 10 minutes to 999 days (in minutes)
|
||||
const MIN_DURATION_MINUTES = 10;
|
||||
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
+1
-2
@@ -27,7 +27,7 @@ const { t } = useI18n();
|
||||
|
||||
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
|
||||
const DEFAULT_CONVERSATION_LIMIT = 10;
|
||||
const MIN_CONVERSATION_LIMIT = 0;
|
||||
const MIN_CONVERSATION_LIMIT = 1;
|
||||
const MAX_CONVERSATION_LIMIT = 100000;
|
||||
|
||||
const selectedInboxIds = computed(
|
||||
@@ -42,7 +42,6 @@ const availableInboxes = computed(() =>
|
||||
|
||||
const isLimitValid = limit => {
|
||||
return (
|
||||
Number.isInteger(limit.conversationLimit) &&
|
||||
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
|
||||
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
|
||||
);
|
||||
|
||||
-1
@@ -56,7 +56,6 @@ const state = reactive({ ...initialState });
|
||||
const urlValidators = {
|
||||
shouldBeAValidURLPattern: value => {
|
||||
try {
|
||||
// eslint-disable-next-line
|
||||
new URLPattern(value);
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
+195
-51
@@ -1,63 +1,207 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
||||
};
|
||||
|
||||
const priorityLabels = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'CONVERSATION.PRIORITY.OPTIONS.URGENT',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'CONVERSATION.PRIORITY.OPTIONS.HIGH',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'CONVERSATION.PRIORITY.OPTIONS.MEDIUM',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'CONVERSATION.PRIORITY.OPTIONS.LOW',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.priority && icons[props.priority]) {
|
||||
return icons[props.priority];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
||||
});
|
||||
|
||||
const tooltipContent = computed(() => {
|
||||
if (props.priority && priorityLabels[props.priority]) {
|
||||
return t(priorityLabels[props.priority]);
|
||||
}
|
||||
|
||||
if (props.showEmpty) {
|
||||
return t('CONVERSATION.PRIORITY.OPTIONS.NONE');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: tooltipContent,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-1
@@ -26,7 +26,6 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
+2
-18
@@ -1,22 +1,8 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -33,8 +19,6 @@ const locales = [
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -17,10 +17,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isDraft: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -41,28 +37,11 @@ const { t } = useI18n();
|
||||
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
||||
|
||||
const localeMenuLabels = computed(() => ({
|
||||
'change-default': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
||||
),
|
||||
'move-to-draft': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
||||
),
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
buildLocaleMenuItems({
|
||||
isDefault: props.isDefault,
|
||||
isDraft: props.isDraft,
|
||||
}).map(item => ({
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
...item,
|
||||
label: localeMenuLabels.value[item.action],
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -77,7 +56,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ localeLabel }}
|
||||
{{ locale }} ({{ localeCode }})
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
@@ -85,12 +64,6 @@ const handleAction = ({ action, value }) => {
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isDraft"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -113,7 +86,6 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="localeMenuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
|
||||
+2
-48
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -24,20 +24,12 @@ const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
const localeStatus = ref('published');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const draftedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales
|
||||
.filter(locale => locale.draft)
|
||||
.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
@@ -49,44 +41,17 @@ const locales = computed(() => {
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{
|
||||
value: 'published',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
||||
},
|
||||
{
|
||||
value: 'draft',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
localeStatus.value = 'published';
|
||||
};
|
||||
|
||||
watch(localeStatus, value => {
|
||||
if (!value) {
|
||||
localeStatus.value = 'published';
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
const updatedDraftLocales =
|
||||
localeStatus.value === 'draft'
|
||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
||||
: draftedLocales.value;
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: {
|
||||
allowed_locales: updatedLocales,
|
||||
draft_locales: updatedDraftLocales,
|
||||
default_locale: props.portal?.meta?.default_locale,
|
||||
},
|
||||
});
|
||||
@@ -97,7 +62,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
resetForm();
|
||||
selectedLocale.value = '';
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
@@ -122,7 +87,6 @@ defineExpose({ dialogRef });
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@close="resetForm"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -134,16 +98,6 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="localeStatus"
|
||||
:options="statusOptions"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,6 @@ const isLocaleDefault = code => {
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
@@ -40,7 +39,6 @@ const updatePortalLocales = async ({
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
draft_locales: newDraftLocales,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -55,12 +53,8 @@ const updatePortalLocales = async ({
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const newDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
@@ -87,15 +81,11 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
const updatedDraftLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
newDraftLocales: updatedDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
@@ -108,46 +98,9 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const currentDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
const newDraftLocales = shouldDraft
|
||||
? [...new Set([...currentDraftLocales, localeCode])]
|
||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: props.portal.meta.default_locale,
|
||||
messageKey,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: true,
|
||||
messageKey: 'DRAFT_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const publishLocale = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: false,
|
||||
messageKey: 'PUBLISH_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'move-to-draft') {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -161,7 +114,6 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
|
||||
-12
@@ -4,49 +4,37 @@ import LocalesPage from './LocalesPage.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
code: 'en_GB',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
code: 'ms',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
code: 'ml',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
code: 'hi',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
|
||||
@@ -14,10 +14,6 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({ type: Boolean, default: false });
|
||||
@@ -32,8 +28,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
|
||||
@@ -18,18 +18,10 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -70,7 +62,6 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -80,12 +71,6 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +104,7 @@ const handleEnterKey = event => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -35,34 +34,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showMenu: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'select', 'hover']);
|
||||
const emit = defineEmits(['action']);
|
||||
const { checkPermissions } = usePolicy();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const allOptions = [
|
||||
@@ -100,23 +79,12 @@ const handleAction = ({ action, value }) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<CardLayout>
|
||||
<div class="flex gap-1 justify-between w-full">
|
||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||
{{ name }}
|
||||
</span>
|
||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="flex relative items-center group"
|
||||
|
||||
+5
-11
@@ -21,22 +21,16 @@ const emit = defineEmits(['deleteSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const bulkDeleteDialogRef = ref(null);
|
||||
const i18nKey = computed(() => {
|
||||
const i18nTypeMap = {
|
||||
AssistantResponse: 'RESPONSES',
|
||||
AssistantDocument: 'DOCUMENTS',
|
||||
};
|
||||
return i18nTypeMap[props.type];
|
||||
});
|
||||
const i18nKey = computed(() => props.type.toUpperCase());
|
||||
|
||||
const handleBulkDelete = async ids => {
|
||||
if (!ids) return;
|
||||
|
||||
try {
|
||||
await store.dispatch('captainBulkActions/handleBulkDelete', {
|
||||
ids: Array.from(props.bulkIds),
|
||||
type: props.type,
|
||||
});
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkDelete',
|
||||
Array.from(props.bulkIds)
|
||||
);
|
||||
|
||||
emit('deleteSuccess');
|
||||
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||
|
||||
-7
@@ -27,7 +27,6 @@ const initialState = {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
citations: false,
|
||||
contactAttributes: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -60,7 +59,6 @@ const updateStateFromAssistant = assistant => {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
citations: config.feature_citation || false,
|
||||
contactAttributes: config.feature_contact_attributes || false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -81,7 +79,6 @@ const handleBasicInfoUpdate = async () => {
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
feature_citation: state.features.citations,
|
||||
feature_contact_attributes: state.features.contactAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -141,10 +138,6 @@ watch(
|
||||
<input v-model="state.features.citations" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.contactAttributes" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+6
-3
@@ -101,9 +101,12 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+6
-80
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -73,12 +72,8 @@ const DEFAULT_PARAM = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
||||
const MAX_TOOL_NAME_LENGTH = 55;
|
||||
|
||||
const validationRules = {
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
title: { required },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -108,15 +103,9 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
||||
if (failedRule === 'maxLength') {
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
||||
max: MAX_TOOL_NAME_LENGTH,
|
||||
});
|
||||
}
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
@@ -151,30 +140,6 @@ const handleSubmit = async () => {
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref(null);
|
||||
const isTestDisabled = computed(
|
||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!state.endpoint_url) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const { data } = await CustomToolsAPI.test(state);
|
||||
const isOk = data.status >= 200 && data.status < 300;
|
||||
testResult.value = { success: isOk, status: data.status };
|
||||
} catch (e) {
|
||||
const message =
|
||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
||||
testResult.value = { success: false, message };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -283,45 +248,6 @@ const handleTest = async () => {
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-play"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
||||
:is-loading="isTesting"
|
||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{
|
||||
testResult.status
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
||||
status: testResult.status,
|
||||
})
|
||||
: testResult.message
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
-12
@@ -1,11 +1,8 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -13,15 +10,6 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -29,12 +29,6 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -49,7 +43,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -32,7 +32,6 @@ const convertToMinutes = newValue => {
|
||||
|
||||
const transformedValue = computed({
|
||||
get() {
|
||||
if (duration.value == null) return null;
|
||||
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
|
||||
if (unit.value === DURATION_UNITS.HOURS)
|
||||
return Math.floor(duration.value / 60);
|
||||
@@ -42,10 +41,6 @@ const transformedValue = computed({
|
||||
return 0;
|
||||
},
|
||||
set(newValue) {
|
||||
if (newValue == null || newValue === '') {
|
||||
duration.value = null;
|
||||
return;
|
||||
}
|
||||
let minuteValue = convertToMinutes(newValue);
|
||||
|
||||
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
|
||||
@@ -58,7 +53,6 @@ const transformedValue = computed({
|
||||
// this might create some confusion, especially when saving
|
||||
// this watcher fixes it by rounding the duration basically, to the nearest unit value
|
||||
watch(unit, () => {
|
||||
if (duration.value == null) return;
|
||||
let adjustedValue = convertToMinutes(transformedValue.value);
|
||||
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
|
||||
});
|
||||
|
||||
@@ -99,7 +99,6 @@ import { useBranding } from 'shared/composables/useBranding';
|
||||
* @property {number} inboxId - The ID of the inbox to which the message belongs
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line vue/define-macros-order
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
messageType: {
|
||||
@@ -124,12 +123,12 @@ const props = defineProps({
|
||||
createdAt: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
currentUserId: { type: Number, required: true }, // eslint-disable-line vue/no-unused-properties
|
||||
groupWithNext: { type: Boolean, default: false },
|
||||
inboxId: { type: Number, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
inboxId: { type: Number, default: null },
|
||||
inboxSupportsReplyTo: { type: Object, default: () => ({}) },
|
||||
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
isEmailInbox: { type: Boolean, default: false },
|
||||
private: { type: Boolean, default: false },
|
||||
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
sender: { type: Object, default: null },
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
|
||||
@@ -67,9 +67,6 @@ const isSent = computed(() => {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// API inbox messages use real sent/delivered/read status values from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (isALineChannel.value) return true;
|
||||
|
||||
@@ -89,10 +86,8 @@ const isDelivered = computed(() => {
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// API inbox messages use real delivered status from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
// All messages marked as delivered for the web widget inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value) {
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
if (isALineChannel.value) {
|
||||
|
||||
@@ -63,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasCustomTools = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
|
||||
) ||
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -374,18 +364,14 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
...(hasCustomTools.value
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
|
||||
@@ -107,8 +107,7 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -828,8 +828,6 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focusEditorInputField });
|
||||
|
||||
// BUS Event to insert text or markdown into the editor at the
|
||||
// current cursor position.
|
||||
// Components using this
|
||||
|
||||
@@ -18,9 +18,7 @@ const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
// eslint-disable-next-line default-param-last
|
||||
plugins = [],
|
||||
// eslint-disable-next-line default-param-last
|
||||
methods = {},
|
||||
enabledMenuOptions
|
||||
) => {
|
||||
|
||||
@@ -35,8 +35,7 @@ export default {
|
||||
default: '00:00',
|
||||
},
|
||||
// inbox prop is used in /mixins/inboxMixin,
|
||||
// remove this props when refactoring to composable if not needed
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
// remove this props when refactoring to composable if not neededs
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import lamejs from '@breezystack/lamejs';
|
||||
|
||||
const writeString = (view, offset, string) => {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
@@ -29,9 +28,8 @@ const bufferToWav = async (buffer, numChannels, sampleRate) => {
|
||||
|
||||
// WAV Data
|
||||
const offset = 44;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let channel = 0; channel < numChannels; channel++) {
|
||||
const sample = Math.max(
|
||||
-1,
|
||||
|
||||
@@ -10,7 +10,7 @@ import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
@@ -305,7 +305,7 @@ const deleteConversation = () => {
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
class="flex items-center gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
@@ -317,10 +317,7 @@ const deleteConversation = () => {
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'PriorityMark',
|
||||
props: {
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
validate: value =>
|
||||
[...Object.values(CONVERSATION_PRIORITY), ''].includes(value),
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
CONVERSATION_PRIORITY,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
tooltipText() {
|
||||
return this.$t(
|
||||
`CONVERSATION.PRIORITY.OPTIONS.${this.priority.toUpperCase()}`
|
||||
);
|
||||
},
|
||||
isUrgent() {
|
||||
return this.priority === CONVERSATION_PRIORITY.URGENT;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<span
|
||||
v-if="priority"
|
||||
v-tooltip="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1500, hide: 0 },
|
||||
}"
|
||||
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
|
||||
:class="{
|
||||
'bg-n-ruby-4 text-n-ruby-10': isUrgent,
|
||||
'bg-n-slate-4 text-n-slate-11': !isUrgent,
|
||||
}"
|
||||
>
|
||||
<fluent-icon
|
||||
:icon="`priority-${priority.toLowerCase()}`"
|
||||
:size="isUrgent ? 12 : 14"
|
||||
class="flex-shrink-0"
|
||||
view-box="0 0 14 14"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
@@ -99,7 +99,6 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const messageEditor = useTemplateRef('messageEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
@@ -110,7 +109,6 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
messageEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
@@ -253,9 +251,6 @@ export default {
|
||||
if (this.isAnInstagramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.INSTAGRAM;
|
||||
}
|
||||
if (this.isATelegramChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TELEGRAM;
|
||||
}
|
||||
if (this.isATiktokChannel) {
|
||||
return MESSAGE_MAX_LENGTH.TIKTOK;
|
||||
}
|
||||
@@ -512,7 +507,7 @@ export default {
|
||||
);
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
|
||||
// A hacky fix to solve the drag and drop
|
||||
// Is showing on top of new conversation modal drag and drop
|
||||
@@ -527,7 +522,7 @@ export default {
|
||||
unmounted() {
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
||||
emitter.off(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
@@ -548,10 +543,7 @@ export default {
|
||||
},
|
||||
setCopilotAcceptedMessage(message, replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
this.copilotAcceptedMessages[key] = trimContent(
|
||||
message || '',
|
||||
this.maxLength
|
||||
);
|
||||
this.copilotAcceptedMessages[key] = trimContent(message || '');
|
||||
},
|
||||
clearCopilotAcceptedMessage(replyType = this.replyType) {
|
||||
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
|
||||
@@ -609,7 +601,7 @@ export default {
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
const key = this.getDraftKey(conversationId, replyType);
|
||||
const draftToSave = trimContent(this.message || '', this.maxLength);
|
||||
const draftToSave = trimContent(this.message || '');
|
||||
|
||||
this.$store.dispatch('draftMessages/set', {
|
||||
key,
|
||||
@@ -1199,15 +1191,6 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onReplyToMessage() {
|
||||
this.fetchAndSetReplyTo();
|
||||
if (this.inReplyTo) {
|
||||
this.$nextTick(() => {
|
||||
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
|
||||
this.messageEditor?.focusEditorInputField(pos);
|
||||
});
|
||||
}
|
||||
},
|
||||
resetReplyToMessage() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
@@ -1330,7 +1313,6 @@ export default {
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
ref="messageEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
|
||||
@@ -32,7 +32,6 @@ const emit = defineEmits(['dismiss']);
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'agents/getAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -37,7 +37,6 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
|
||||
@@ -37,12 +37,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReconnect = () => {
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onDisconnected = () => {
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
|
||||
};
|
||||
@@ -90,7 +88,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('updateConversation', data);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onLogout = () => AuthAPI.logout();
|
||||
|
||||
onMessageCreated = data => {
|
||||
@@ -106,7 +103,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReload = () => window.location.reload();
|
||||
|
||||
onStatusChange = data => {
|
||||
@@ -161,7 +157,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
}, 30000);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchConversationStats = () => {
|
||||
emitter.emit('fetch_conversation_stats');
|
||||
};
|
||||
|
||||
@@ -212,9 +212,8 @@ export const isCustomAttribute = (attrs, key) => {
|
||||
};
|
||||
|
||||
export const generateCustomAttributes = (
|
||||
// eslint-disable-next-line default-param-last
|
||||
conversationAttributes = [],
|
||||
// eslint-disable-next-line default-param-last
|
||||
|
||||
contactAttributes = [],
|
||||
conversationlabel,
|
||||
contactlabel
|
||||
|
||||
@@ -61,7 +61,6 @@ export class EmailQuoteExtractor {
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const selector of QUOTE_INDICATORS) {
|
||||
if (tempDiv.querySelector(selector)) {
|
||||
return true;
|
||||
|
||||
@@ -133,55 +133,20 @@ export const ARTICLE_TABS_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCALE_MENU_ITEMS = {
|
||||
makeDefault: {
|
||||
export const LOCALE_MENU_ITEMS = [
|
||||
{
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
|
||||
action: 'change-default',
|
||||
value: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
},
|
||||
moveToDraft: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
|
||||
action: 'move-to-draft',
|
||||
value: 'draft',
|
||||
icon: 'i-lucide-eye-off',
|
||||
},
|
||||
publishLocale: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
|
||||
action: 'publish-locale',
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
delete: {
|
||||
{
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
};
|
||||
|
||||
const disableLocaleMenuItems = menuItems =>
|
||||
menuItems.map(item => ({ ...item, disabled: true }));
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
];
|
||||
|
||||
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
|
||||
published: ['archive', 'draft'],
|
||||
|
||||
@@ -18,7 +18,6 @@ export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
.register('/sw.js')
|
||||
.then(registration => callback(registration))
|
||||
.catch(registrationError => {
|
||||
// eslint-disable-next-line
|
||||
console.log('SW registration failed: ', registrationError);
|
||||
});
|
||||
};
|
||||
@@ -69,7 +68,6 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
onSuccess();
|
||||
})
|
||||
.catch(error => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Push subscription registration failed:', error);
|
||||
useAlert('This browser does not support desktop notification');
|
||||
});
|
||||
@@ -77,7 +75,6 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
|
||||
export const requestPushPermissions = ({ onSuccess }) => {
|
||||
if (!('Notification' in window)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('Notification is not supported');
|
||||
useAlert('This browser does not support desktop notification');
|
||||
} else if (Notification.permission === 'granted') {
|
||||
|
||||
@@ -166,8 +166,6 @@ const TOD_TO_MERIDIEM = {
|
||||
evening: 'pm',
|
||||
night: 'pm',
|
||||
};
|
||||
const CJK_CHAR_RE =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
||||
|
||||
// ─── Translation Cache ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -280,13 +278,8 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const substituteLocalTokens = (text, pairs) => {
|
||||
let r = text;
|
||||
pairs.forEach(([local, en]) => {
|
||||
if (CJK_CHAR_RE.test(local)) {
|
||||
const re = new RegExp(escapeRegex(local), 'g');
|
||||
r = r.replace(re, ` ${en} `);
|
||||
} else {
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
}
|
||||
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
|
||||
r = r.replace(re, en);
|
||||
});
|
||||
return r;
|
||||
};
|
||||
|
||||
@@ -82,9 +82,6 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
|
||||
|
||||
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
|
||||
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
|
||||
const RELATIVE_DURATION_AFTER_RE = new RegExp(
|
||||
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
|
||||
);
|
||||
const DURATION_FROM_NOW_RE = new RegExp(
|
||||
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
|
||||
);
|
||||
@@ -92,9 +89,6 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
|
||||
const RELATIVE_DAY_TOD_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
|
||||
);
|
||||
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
|
||||
);
|
||||
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
|
||||
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
|
||||
);
|
||||
@@ -251,7 +245,6 @@ const matchDuration = (text, now) => {
|
||||
|
||||
return (
|
||||
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
|
||||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
|
||||
);
|
||||
};
|
||||
@@ -310,13 +303,6 @@ const matchRelativeDay = (text, now) => {
|
||||
);
|
||||
}
|
||||
|
||||
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
|
||||
if (dayMeridiemMatch) {
|
||||
const [, dayKey, meridiem] = dayMeridiemMatch;
|
||||
const hours = meridiem === 'am' ? 9 : 14;
|
||||
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
|
||||
}
|
||||
|
||||
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
|
||||
if (dayAtTimeMatch) {
|
||||
const [, dayKey, timeRaw] = dayAtTimeMatch;
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
buildLocaleMenuItems,
|
||||
buildPortalArticleURL,
|
||||
buildPortalURL,
|
||||
} from '../portalHelper';
|
||||
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
|
||||
|
||||
describe('PortalHelper', () => {
|
||||
describe('buildPortalURL', () => {
|
||||
@@ -72,39 +68,4 @@ describe('PortalHelper', () => {
|
||||
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1626,24 +1626,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const zhTWSnoozeTranslations = {
|
||||
UNITS: {
|
||||
HOUR: '小時',
|
||||
HOURS: '小時',
|
||||
DAY: '天',
|
||||
DAYS: '天',
|
||||
},
|
||||
HALF: '半',
|
||||
RELATIVE: {
|
||||
TOMORROW: '明天',
|
||||
},
|
||||
MERIDIEM: {
|
||||
AM: '上午',
|
||||
PM: '下午',
|
||||
},
|
||||
AFTER: '後',
|
||||
};
|
||||
|
||||
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
|
||||
it('Arabic "غد" does not produce half-duration suggestions', () => {
|
||||
const results = generateDateSuggestions('غد', now, {
|
||||
@@ -1739,37 +1721,6 @@ describe('generateDateSuggestions — localized input regressions', () => {
|
||||
expect(results[0].date.getHours()).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh_TW compact CJK inputs', () => {
|
||||
const options = {
|
||||
translations: zhTWSnoozeTranslations,
|
||||
locale: 'zh-TW',
|
||||
};
|
||||
|
||||
it('parses "2小時後" (2 hours from now) without spaces', () => {
|
||||
const results = generateDateSuggestions('2小時後', now, options);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].date.getDate()).toBe(16);
|
||||
expect(results[0].date.getHours()).toBe(12);
|
||||
expect(results[0].date.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('parses "半天" (half day) without spaces', () => {
|
||||
const results = generateDateSuggestions('半天', now, options);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].date.getDate()).toBe(16);
|
||||
expect(results[0].date.getHours()).toBe(22);
|
||||
expect(results[0].date.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => {
|
||||
const results = generateDateSuggestions('明天 上午', now, options);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].date.getDate()).toBe(17);
|
||||
expect(results[0].date.getHours()).toBe(9);
|
||||
expect(results[0].date.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-space duration suggestions', () => {
|
||||
|
||||
@@ -288,10 +288,9 @@ describe('templateHelper', () => {
|
||||
});
|
||||
|
||||
it('should handle special characters in template variables', () => {
|
||||
/* eslint-disable no-template-curly-in-string */
|
||||
const templateText =
|
||||
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
|
||||
/* eslint-enable no-template-curly-in-string */
|
||||
|
||||
const processedParams = {
|
||||
body: {
|
||||
user_name: 'John & Jane',
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
"LOADING_EDITOR": "Loading editor...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"COUNT": "{n} bot | {n} bots",
|
||||
"SEARCH_PLACEHOLDER": "Search bots...",
|
||||
"NO_RESULTS": "No bots found matching your search",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "System",
|
||||
"AVATAR": {
|
||||
@@ -37,8 +34,7 @@
|
||||
"LOADING": "Fetching bots...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "Webhook URL",
|
||||
"ACTIONS": "Actions"
|
||||
"URL": "Webhook URL"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"ADMINISTRATOR": "Administrator",
|
||||
"AGENT": "Agent"
|
||||
},
|
||||
"COUNT": "{n} agent | {n} agents",
|
||||
"LIST": {
|
||||
"404": "There are no agents associated to this account",
|
||||
"TITLE": "Manage agents in your team",
|
||||
@@ -97,8 +96,6 @@
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search agents...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "No results found."
|
||||
},
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
@@ -66,10 +63,6 @@
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"HEADER": "Automation",
|
||||
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
|
||||
"LEARN_MORE": "Learn more about automation",
|
||||
"COUNT": "{n} automation | {n} automations",
|
||||
"HEADER_BTN_TXT": "Create Automation",
|
||||
"HEADER_BTN_TXT": "Add Automation Rule",
|
||||
"LOADING": "Fetching automation rules",
|
||||
"SEARCH_PLACEHOLDER": "Search automation rules...",
|
||||
"NO_RESULTS": "No automation rules found matching your search",
|
||||
"ADD": {
|
||||
"TITLE": "Add Automation Rule",
|
||||
"SUBMIT": "Create",
|
||||
@@ -45,9 +42,9 @@
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"ACTIVE": "Active",
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
"CREATED_ON": "Created on"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
},
|
||||
@@ -153,8 +150,7 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation",
|
||||
"PENDING_CONVERSATION": "Mark conversation as pending"
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -22,10 +22,6 @@
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
"RESOLVE": {
|
||||
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
|
||||
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"HEADER": "Canned Responses",
|
||||
"LEARN_MORE": "Learn more about canned responses",
|
||||
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
|
||||
"COUNT": "{n} canned response | {n} canned responses",
|
||||
"HEADER_BTN_TXT": "Add canned response",
|
||||
"LOADING": "Fetching canned responses...",
|
||||
"SEARCH_PLACEHOLDER": "Search canned responses...",
|
||||
"NO_RESULTS": "No canned responses found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"LIST": {
|
||||
"404": "There are no canned responses available in this account.",
|
||||
|
||||
@@ -76,9 +76,6 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "Pending Response: Shortest first"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@
|
||||
"SWITCH_VIEW_LAYOUT": "Switch the layout",
|
||||
"DASHBOARD_APP_TAB_MESSAGES": "Messages",
|
||||
"UNVERIFIED_SESSION": "The identity of this user is not verified",
|
||||
"NO_MESSAGE_1": "የደንበኞች መልእክቶች በኢንቦክስዎ አልተገኙም።",
|
||||
"NO_MESSAGE_2": " ወደ ገፅዎ መልእክት ለመላክ!",
|
||||
"NO_INBOX_1": "እሺ! አሁን ምንም ኢንቦክስ አልጨመሩም።",
|
||||
"NO_INBOX_2": " ለመጀመር",
|
||||
"NO_INBOX_AGENT": "ወይ! ምንም ኢንቦክስ አባል አይደለህም። እባክዎ አስተዳዳሪዎን ያነጋግሩ",
|
||||
"SEARCH_MESSAGES": "መልእክቶችን በውይይቶች ውስጥ ይፈልጉ",
|
||||
"NO_MESSAGE_1": "Uh oh! Looks like there are no messages from customers in your inbox.",
|
||||
"NO_MESSAGE_2": " to send a message to your page!",
|
||||
"NO_INBOX_1": "Hola! Looks like you haven't added any inboxes yet.",
|
||||
"NO_INBOX_2": " to get started",
|
||||
"NO_INBOX_AGENT": "Uh Oh! Looks like you are not part of any inbox. Please contact your administrator",
|
||||
"SEARCH_MESSAGES": "Search for messages in conversations",
|
||||
"VIEW_ORIGINAL": "View original",
|
||||
"VIEW_TRANSLATED": "View translated",
|
||||
"EMPTY_STATE": {
|
||||
@@ -19,19 +19,19 @@
|
||||
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
|
||||
},
|
||||
"SEARCH": {
|
||||
"TITLE": "መልእክቶችን ይፈልጉ",
|
||||
"TITLE": "Search messages",
|
||||
"RESULT_TITLE": "Search Results",
|
||||
"LOADING_MESSAGE": "መረጃ በማስተናገድ ላይ...",
|
||||
"LOADING_MESSAGE": "Crunching data...",
|
||||
"PLACEHOLDER": "Type any text to search messages",
|
||||
"NO_MATCHING_RESULTS": "No results found."
|
||||
},
|
||||
"UNREAD_MESSAGES": "Unread Messages",
|
||||
"UNREAD_MESSAGE": "Unread Message",
|
||||
"CLICK_HERE": "እዚህ ጠቅ ያድርጉ",
|
||||
"LOADING_INBOXES": "ኢንቦክሶች በመጫን ላይ",
|
||||
"LOADING_CONVERSATIONS": "ውይይቶች በመጫን ላይ",
|
||||
"CANNOT_REPLY": "ምክንያቱን በመነሳት መልስ ማድረግ አይችሉም",
|
||||
"24_HOURS_WINDOW": "የ24 ሰዓት መልእክት ጊዜ ገደብ",
|
||||
"CLICK_HERE": "Click here",
|
||||
"LOADING_INBOXES": "Loading inboxes",
|
||||
"LOADING_CONVERSATIONS": "Loading Conversations",
|
||||
"CANNOT_REPLY": "You cannot reply due to",
|
||||
"24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"48_HOURS_WINDOW": "48 hour message window restriction",
|
||||
"API_HOURS_WINDOW": "ለዚህ ውይይት መመለስ በ{hours} ሰአታት ውስጥ ብቻ ይቻላል",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
@@ -44,9 +44,9 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "ይህ የInstagram መለያ ወደ አዲሱ የInstagram ቻናል ገቢ ሳጥን ተዛውሯል። ሁሉም አዲስ መልዕክቶች በዚያ ይታያሉ። ከአሁን ጀምሮ ከዚህ ውይይት መልዕክቶች መላክ አትችሉም።",
|
||||
"REPLYING_TO": "ለዚህ ትመልሳለህ፦",
|
||||
"REMOVE_SELECTION": "ምርጫ አስወግድ",
|
||||
"DOWNLOAD": "አውርድ",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
"UNKNOWN_FILE_TYPE": "Unknown File",
|
||||
"SAVE_CONTACT": "Save Contact",
|
||||
"NO_CONTENT": "No content to display",
|
||||
@@ -61,7 +61,6 @@
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -85,13 +84,13 @@
|
||||
"YOU_ANSWERED": "You answered"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "ተፈትኗል",
|
||||
"REOPEN_ACTION": "እንደገና ክፈት",
|
||||
"RESOLVE_ACTION": "Resolve",
|
||||
"REOPEN_ACTION": "Reopen",
|
||||
"OPEN_ACTION": "Open",
|
||||
"MORE_ACTIONS": "ተጨማሪ እርምጃዎች",
|
||||
"OPEN": "ተጨማሪ",
|
||||
"CLOSE": "ዝጋ",
|
||||
"DETAILS": "ዝርዝሮች",
|
||||
"OPEN": "More",
|
||||
"CLOSE": "Close",
|
||||
"DETAILS": "details",
|
||||
"SNOOZED_UNTIL": "Snoozed until",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
|
||||
@@ -174,10 +173,6 @@
|
||||
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign label. Please try again."
|
||||
},
|
||||
"LABEL_REMOVAL": {
|
||||
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||
"FAILED": "Couldn't remove label. Please try again."
|
||||
},
|
||||
"TEAM_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign team. Please try again."
|
||||
@@ -188,21 +183,17 @@
|
||||
"MESSAGE_SIGN_TOOLTIP": "Message signature",
|
||||
"ENABLE_SIGN_TOOLTIP": "Enable signature",
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MSG_INPUT": "አዲስ መስመር ለማስገባት Shift + enter ይጠቀሙ። '/' በመጀመር የተዘጋጀ ምላሽ ይምረጡ።",
|
||||
"PRIVATE_MSG_INPUT": "አዲስ መስመር ለማስገባት Shift + enter ይጠቀሙ። ይህ ለወኪሎች ብቻ ይታያል",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "ኮፒሎት ተጨማሪ እባብነቶች ስጡው, ወይም ሌላ ማንኛውንም ጥያቄ ያቀርቡ... ተከትሎ ለማስተላለፊያ ኤንተር ይጫኑ።",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
"REPLYBOX": {
|
||||
"REPLY": "መልስ",
|
||||
"PRIVATE_NOTE": "የግል ማስታወሻ",
|
||||
"SEND": "ላክ",
|
||||
"CREATE": "ማስታወሻ አክል",
|
||||
"REPLY": "Reply",
|
||||
"PRIVATE_NOTE": "Private Note",
|
||||
"SEND": "Send",
|
||||
"CREATE": "Add Note",
|
||||
"INSERT_READ_MORE": "Read more",
|
||||
"DISMISS_REPLY": "Dismiss reply",
|
||||
"REPLYING_TO": "Replying to:",
|
||||
@@ -214,7 +205,7 @@
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
|
||||
"": "",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
@@ -245,10 +236,10 @@
|
||||
"EXPAND": "Expand preview"
|
||||
}
|
||||
},
|
||||
"VISIBLE_TO_AGENTS": "የግል ማስታወሻ፡ ለአንተና ቡድንህ ብቻ ይታያል",
|
||||
"CHANGE_STATUS": "የውይይቱ ሁኔታ ተቀይሯል",
|
||||
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
|
||||
"CHANGE_STATUS": "Conversation status changed",
|
||||
"CHANGE_STATUS_FAILED": "Conversation status change failed",
|
||||
"CHANGE_AGENT": "የውይይቱ ተመድብ ተቀይሯል",
|
||||
"CHANGE_AGENT": "Conversation Assignee changed",
|
||||
"CHANGE_AGENT_FAILED": "Assignee change failed",
|
||||
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
|
||||
"ASSIGN_LABEL_FAILED": "Label assignment failed",
|
||||
@@ -256,12 +247,9 @@
|
||||
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
|
||||
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
||||
"FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation",
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
"BOT": "Bot",
|
||||
"NATIVE_APP": "Native app",
|
||||
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
|
||||
"SEND_FAILED": "Couldn't send message! Try again",
|
||||
"TRY_AGAIN": "retry",
|
||||
"ASSIGNMENT": {
|
||||
@@ -300,20 +288,19 @@
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"TITLE": "የውይይት ጽሑፍ ላክ",
|
||||
"DESC": "የውይይቱን ጽሑፍ ቅጂ ወደ ተጠቃሚው ኢሜይል ላክ",
|
||||
"SUBMIT": "አስገባ",
|
||||
"CANCEL": "ይቅር",
|
||||
"SEND_EMAIL_SUCCESS": "የቻት አጭር መግለጫው በተሳካ ሁኔታ ተልኳል",
|
||||
"SEND_EMAIL_ERROR": "ስህተት ተፈጥሯል፣ እባክዎ ደግመው ይሞክሩ",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"TITLE": "Send conversation transcript",
|
||||
"DESC": "Send a copy of the conversation transcript to the specified email address",
|
||||
"SUBMIT": "Submit",
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "መግለጫውን ለደንበኛው ይላኩ",
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
"SEND_TO_OTHER_EMAIL_ADDRESS": "መግለጫውን ወደ ሌላ ኢሜይል አድራሻ ይላኩ",
|
||||
"SEND_TO_OTHER_EMAIL_ADDRESS": "Send the transcript to another email address",
|
||||
"EMAIL": {
|
||||
"PLACEHOLDER": "ኢሜይል አድራሻ ያስገቡ",
|
||||
"ERROR": "ትክክለኛ ኢሜይል አድራሻ ያስገቡ"
|
||||
"PLACEHOLDER": "Enter an email address",
|
||||
"ERROR": "Please enter a valid email address"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
"HEADER": "Custom Roles",
|
||||
"LEARN_MORE": "Learn more about custom roles",
|
||||
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"COUNT": "{n} custom role | {n} custom roles",
|
||||
"HEADER_BTN_TXT": "Add custom role",
|
||||
"LOADING": "Fetching custom roles...",
|
||||
"SEARCH_PLACEHOLDER": "Search custom roles...",
|
||||
"NO_RESULTS": "No custom roles found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create custom roles",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -16,8 +13,6 @@
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,6 @@
|
||||
},
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "Accept",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "Preferred"
|
||||
},
|
||||
"CHOICE_TOGGLE": {
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,6 @@
|
||||
},
|
||||
"COMMAND_BAR": {
|
||||
"SEARCH_PLACEHOLDER": "Search or jump to",
|
||||
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,6 @@
|
||||
"FETCHING": "Fetching Integrations",
|
||||
"NO_HOOK_CONFIGURED": "There are no {integrationId} integrations configured in this account.",
|
||||
"HEADER": "Applications",
|
||||
"COUNT": "{n} integration | {n} integrations",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"NO_RESULTS": "No results found matching your search",
|
||||
"STATUS": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
@@ -34,7 +31,6 @@
|
||||
"LIST": {
|
||||
"FETCHING": "Fetching integration hooks",
|
||||
"INBOX": "Inbox",
|
||||
"ACTIONS": "Actions",
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user