Merge branch 'release/4.13.0'

This commit is contained in:
Sojan Jose
2026-04-16 19:02:52 +05:30
1017 changed files with 46358 additions and 10647 deletions

65
.annotaterb.yml Normal file
View File

@@ -0,0 +1,65 @@
---
: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:
- ''

View File

@@ -2,3 +2,8 @@
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

View File

@@ -40,6 +40,8 @@ gem 'json_refs'
gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down'
# SSRF-safe URL fetching
gem 'ssrf_filter', '~> 1.5'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2

View File

@@ -942,6 +942,7 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
squasher (0.7.2)
ssrf_filter (1.5.0)
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (18.0.1)
@@ -1158,6 +1159,7 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
ssrf_filter (~> 1.5)
stackprof
stripe (~> 18.0)
telephone_number

View File

@@ -1 +1 @@
4.12.1
4.13.0

View File

@@ -1,4 +1,6 @@
class Email::BaseBuilder
include EmailAddressParseable
pattr_initialize [:inbox!]
private
@@ -47,8 +49,4 @@ class Email::BaseBuilder
# can save it in the format "Name <email@domain.com>"
parse_email(account.support_email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
end

View File

@@ -34,6 +34,10 @@ 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

View File

@@ -116,6 +116,8 @@ 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?

View File

@@ -66,6 +66,12 @@ 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') }

View File

@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
@@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
SafeFetch.fetch(params[:external_url].to_s) do |result|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue SafeFetch::HttpError => e
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
rescue SafeFetch::FetchError
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
rescue SafeFetch::FileTooLargeError
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
rescue SafeFetch::UnsupportedContentTypeError
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
rescue SafeFetch::Error
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)

View File

@@ -30,9 +30,20 @@ class Api::V1::AccountsController < Api::BaseController
locale: account_params[:locale],
user: current_user
).perform
enqueue_branding_enrichment
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
# Authenticated users (dashboard "add account") and api_only signups
# need the full response with account_id. API-only deployments have no
# frontend to handle the email confirmation flow, so they need auth
# tokens to proceed.
# Unauthenticated web signup returns only the email — no session is
# created until the user confirms via the email link.
if current_user || api_only_signup?
send_auth_headers(@user)
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render json: { email: @user.email }
end
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@@ -59,6 +70,16 @@ class Api::V1::AccountsController < Api::BaseController
private
def enqueue_branding_enrichment
return if account_params[:email].blank?
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
rescue StandardError => e
# Enrichment is optional — never let queue/Redis failures abort signup
ChatwootExceptionTracker.new(e).capture_exception
end
def ensure_account_name
# ensure that account_name and user_full_name is present
# this is becuase the account builder and the models validations are not triggered
@@ -103,6 +124,15 @@ class Api::V1::AccountsController < Api::BaseController
raise ActionController::RoutingError, 'Not Found' unless GlobalConfigService.account_signup_enabled?
end
def api_only_signup?
# CW_API_ONLY_SERVER is the canonical flag for API-only deployments.
# ENABLE_ACCOUNT_SIGNUP='api_only' is a legacy sentinel for the same purpose.
# Read ENABLE_ACCOUNT_SIGNUP raw from InstallationConfig because GlobalConfig.get
# typecasts it to boolean, coercing 'api_only' to true.
ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false)) ||
InstallationConfig.find_by(name: 'ENABLE_ACCOUNT_SIGNUP')&.value.to_s == 'api_only'
end
def validate_captcha
raise ActionController::InvalidAuthenticityToken, 'Invalid Captcha' unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
end

View File

@@ -43,7 +43,15 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_conversation
@conversation = create_conversation if conversation.nil?
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?
end
def message_finder_params
@@ -64,7 +72,14 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
# 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: []
)
end
def set_message

View File

@@ -0,0 +1,18 @@
# Unauthenticated endpoint for resending confirmation emails during signup.
# This is a standalone controller (not on DeviseOverrides::ConfirmationsController)
# because OmniAuth middleware intercepts all POST /auth/* routes as provider
# callbacks, and Devise controller filters cause 307 redirects for custom actions.
# Inherits from ActionController::API to avoid both issues entirely.
# Rate-limited by Rack::Attack (IP + email) and gated by hCaptcha.
class Auth::ResendConfirmationsController < ActionController::API
def create
return head(:ok) unless ChatwootCaptcha.new(params[:h_captcha_client_response]).valid?
email = params[:email]
return head(:ok) unless email.is_a?(String)
user = User.from_email(email.strip.downcase)
user&.send_confirmation_instructions unless user&.confirmed?
head :ok
end
end

View File

@@ -10,7 +10,12 @@ 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
@@ -20,7 +25,10 @@ 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
@@ -37,6 +45,7 @@ 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}"
@@ -81,6 +90,15 @@ 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

View File

@@ -1,6 +1,7 @@
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'
@@ -61,7 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content)
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category

View File

@@ -1,6 +1,7 @@
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'

View File

@@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
layout 'portal'
def show
@@ -24,6 +25,7 @@ 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

View File

@@ -18,4 +18,11 @@ 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

View File

@@ -98,7 +98,9 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
if (locale) {
this.$root.$i18n.locale = locale;
}
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');

View File

@@ -25,6 +25,10 @@ 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();

View File

@@ -31,6 +31,12 @@ 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();

View File

@@ -48,6 +48,10 @@ class Inboxes extends CacheEnabledApiClient {
template,
});
}
resetSecret(inboxId) {
return axios.post(`${this.url}/${inboxId}/reset_secret`);
}
}
export default new Inboxes();

View File

@@ -106,6 +106,10 @@ select {
&[disabled] {
@apply field-disabled;
}
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
}
// Textarea

View File

@@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', {
const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', {
type: Number,
default: 10,
default: null,
});
// Duration limits: 10 minutes to 999 days (in minutes)
const MIN_DURATION_MINUTES = 10;
// Duration limits: 1 minute to 999 days (in minutes)
const MIN_DURATION_MINUTES = 1;
const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes
const { t } = useI18n();

View File

@@ -27,7 +27,7 @@ const { t } = useI18n();
const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY';
const DEFAULT_CONVERSATION_LIMIT = 10;
const MIN_CONVERSATION_LIMIT = 1;
const MIN_CONVERSATION_LIMIT = 0;
const MAX_CONVERSATION_LIMIT = 100000;
const selectedInboxIds = computed(
@@ -42,6 +42,7 @@ const availableInboxes = computed(() =>
const isLimitValid = limit => {
return (
Number.isInteger(limit.conversationLimit) &&
limit.conversationLimit >= MIN_CONVERSATION_LIMIT &&
limit.conversationLimit <= MAX_CONVERSATION_LIMIT
);

View File

@@ -103,6 +103,7 @@ const showPagination = computed(() => {
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
class="mb-1"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>

View File

@@ -1,207 +1,63 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = 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>
<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>
<Icon
v-tooltip.top="{
content: tooltipContent,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
</template>

View File

@@ -57,7 +57,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<ButtonGroup
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow !z-20"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { ref, computed, watch } from 'vue';
import { debounce } from '@chatwoot/utils';
import { useI18n } from 'vue-i18n';
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
@@ -27,58 +27,50 @@ const props = defineProps({
const emit = defineEmits([
'saveArticle',
'saveArticleAsync',
'goBack',
'setAuthor',
'setCategory',
'previewArticle',
'createArticle',
]);
const { t } = useI18n();
const isNewArticle = computed(() => !props.article?.id);
const saveAndSync = value => {
emit('saveArticle', value);
};
const localTitle = ref(props.article?.title ?? '');
const localContent = ref(props.article?.content ?? '');
// this will only send the data to the backend
// but will not update the local state preventing unnecessary re-renders
// since the data is already saved and we keep the editor text as the source of truth
const quickSave = debounce(
value => emit('saveArticleAsync', value),
400,
false
// Sync local state when navigating to a different article or on initial fetch
watch(
() => props.article?.id,
newId => {
if (newId) {
localTitle.value = props.article?.title ?? '';
localContent.value = props.article?.content ?? '';
}
}
);
// 2.5 seconds is enough to know that the user has stopped typing and is taking a pause
// so we can save the data to the backend and retrieve the updated data
// this will update the local state with response data
// Only use to save for existing articles
const saveAndSyncDebounced = debounce(saveAndSync, 2500, false);
// Debounced save for new articles
const quickSaveNewArticle = debounce(saveAndSync, 400, false);
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
const handleSave = value => {
if (isNewArticle.value) {
quickSaveNewArticle(value);
} else {
quickSave(value);
saveAndSyncDebounced(value);
}
if (isNewArticle.value) return;
debouncedSave(value);
};
const articleTitle = computed({
get: () => props.article.title,
get: () => localTitle.value,
set: value => {
localTitle.value = value;
handleSave({ title: value });
},
});
const articleContent = computed({
get: () => props.article.content,
get: () => localContent.value,
set: content => {
localContent.value = content;
handleSave({ content });
},
});
@@ -98,6 +90,14 @@ const setCategoryId = categoryId => {
const previewArticle = () => {
emit('previewArticle');
};
const handleCreateArticle = event => {
if (!isNewArticle.value) return;
const title = event?.target?.value || '';
if (title.trim()) {
emit('createArticle', { title, content: localContent.value });
}
};
</script>
<template>
@@ -122,10 +122,11 @@ const previewArticle = () => {
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
placeholder="Title"
autofocus
@blur="handleCreateArticle"
/>
<ArticleEditorControls
:article="article"
@save-article="saveAndSync"
@save-article="values => emit('saveArticle', values)"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
@@ -160,8 +161,12 @@ const previewArticle = () => {
}
.editor-root .has-selection {
.ProseMirror-menubar:not(:has(*)) {
display: none !important;
}
.ProseMirror-menubar {
@apply h-8 rounded-lg !px-2 z-50 bg-n-solid-3 items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
@apply rounded-lg !px-3 !py-1.5 z-50 bg-n-background items-center gap-4 ml-0 mb-0 shadow-md outline outline-1 outline-n-weak;
display: flex;
top: var(--selection-top, auto) !important;
left: var(--selection-left, 0) !important;
@@ -169,15 +174,10 @@ const previewArticle = () => {
position: absolute !important;
.ProseMirror-menuitem {
@apply mr-0;
@apply ltr:mr-0 rtl:ml-0 size-4 flex items-center;
.ProseMirror-icon {
@apply p-0 mt-0 !mr-0;
svg {
width: 20px !important;
height: 20px !important;
}
@apply p-0.5 flex-shrink-0 ltr:mr-2 rtl:ml-2;
}
}

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, useSlots } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -30,23 +30,40 @@ const modelValue = defineModel({
});
const selectedCount = computed(() => modelValue.value.size);
const totalCount = computed(() => props.allItems.length);
const visibleItemIds = computed(() => props.allItems.map(item => item.id));
const visibleItemCount = computed(() => visibleItemIds.value.length);
const selectedVisibleCount = computed(
() => visibleItemIds.value.filter(id => modelValue.value.has(id)).length
);
const hasSelected = computed(() => selectedCount.value > 0);
const isIndeterminate = computed(
() => hasSelected.value && selectedCount.value < totalCount.value
() =>
selectedVisibleCount.value > 0 &&
selectedVisibleCount.value < visibleItemCount.value
);
const allSelected = computed(
() => totalCount.value > 0 && selectedCount.value === totalCount.value
() =>
visibleItemCount.value > 0 &&
selectedVisibleCount.value === visibleItemCount.value
);
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({
get: () => allSelected.value,
set: shouldSelectAll => {
const newSelectedIds = shouldSelectAll
? new Set(props.allItems.map(item => item.id))
: new Set();
modelValue.value = newSelectedIds;
if (!visibleItemCount.value) {
return;
}
const updatedSelection = new Set(modelValue.value);
if (shouldSelectAll) {
visibleItemIds.value.forEach(id => updatedSelection.add(id));
} else {
visibleItemIds.value.forEach(id => updatedSelection.delete(id));
}
modelValue.value = updatedSelection;
},
});
</script>
@@ -63,7 +80,7 @@ const bulkCheckboxState = computed({
v-if="hasSelected"
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-1.5 min-w-0">
<Checkbox
v-model="bulkCheckboxState"
@@ -78,21 +95,23 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }}
</span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div>
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
<slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount">
<Button
:label="deleteLabel"
sm
ruby
ghost
class="!px-1.5"
icon="i-lucide-trash"
@click="emit('bulkDelete')"
/>
</slot>
</div>
</div>
</div>
<div v-else class="flex items-center gap-3">

View File

@@ -12,6 +12,7 @@ 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: {
@@ -34,14 +35,34 @@ 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']);
const emit = defineEmits(['action', 'select', 'hover']);
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 = [
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<CardLayout>
<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>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex gap-2 items-center">
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"

View File

@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
await store.dispatch('captainBulkActions/handleBulkDelete', {
ids: Array.from(props.bulkIds),
type: props.type,
});
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));

View File

@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
defineProps({
featurePrefix: {
type: String,
default: 'CAPTAIN',
},
});
const router = useRouter();
const currentUser = useMapGetter('getCurrentUser');
@@ -31,7 +38,7 @@ const openBilling = () => {
>
<BasePaywallModal
class="mx-auto"
feature-prefix="CAPTAIN"
:feature-prefix="featurePrefix"
:i18n-key="i18nKey"
:is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud"

View File

@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
</Policy>
</div>
</div>
<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"
>
<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">
{{ description }}
</span>
<span

View File

@@ -1,9 +1,10 @@
<script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { required, maxLength } 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';
@@ -72,8 +73,12 @@ 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 },
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
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`);
};
const formErrors = computed(() => ({
@@ -140,6 +151,30 @@ 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>
@@ -248,6 +283,45 @@ const handleSubmit = 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"

View File

@@ -1,8 +1,11 @@
<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');
@@ -10,6 +13,15 @@ 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')"

View File

@@ -36,7 +36,13 @@ const props = defineProps({
},
});
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const emit = defineEmits([
'enterPress',
'escapePress',
'input',
'blur',
'focus',
]);
const modelValue = defineModel({
type: [String, Number],
@@ -49,6 +55,10 @@ const onEnterPress = () => {
emit('enterPress');
};
const onEscapePress = () => {
emit('escapePress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
@@ -102,6 +112,7 @@ defineExpose({
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
@keydown.escape.prevent="onEscapePress"
/>
</div>
</template>

View File

@@ -32,6 +32,7 @@ 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);
@@ -41,6 +42,10 @@ 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);
@@ -53,6 +58,7 @@ 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);
});

View File

@@ -226,4 +226,10 @@ const handleSeeOriginal = () => {
}
}
}
// Email clients (Gmail, Outlook) hardcode dir="ltr" on wrapper elements.
// In RTL apps this forces email content LTR regardless of actual text.
[dir='rtl'] .letter-render [dir='ltr'] {
direction: inherit;
}
</style>

View File

@@ -250,6 +250,12 @@ const menuItems = computed(() => {
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
{
name: 'Unattended',
activeOn: ['conversation_through_unattended'],

View File

@@ -158,9 +158,11 @@ const activeChild = computed(() => {
return rankedPage ?? activeOnPages[0];
}
return navigableChildren.value.find(
child => child.to && route.path.startsWith(resolvePath(child.to))
);
return navigableChildren.value.find(child => {
if (!child.to) return false;
const childPath = resolvePath(child.to);
return route.path === childPath || route.path.startsWith(`${childPath}/`);
});
});
const hasActiveChild = computed(() => {

View File

@@ -56,6 +56,7 @@ import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHe
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnParticipatingView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import {
@@ -113,6 +114,7 @@ const chatLists = useMapGetter('getFilteredConversations');
const mineChatsList = useMapGetter('getMineChats');
const allChatList = useMapGetter('getAllStatusChats');
const unAssignedChatsList = useMapGetter('getUnAssignedChats');
const participatingChatsList = useMapGetter('getParticipatingChats');
const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
@@ -296,13 +298,15 @@ const pageTitle = computed(() => {
if (props.label) {
return `#${props.label}`;
}
if (props.conversationType === 'mention') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.MENTION) {
return t('CHAT_LIST.MENTION_HEADING');
}
if (props.conversationType === 'participating') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
return t('CONVERSATION_PARTICIPANTS.SIDEBAR_MENU_TITLE');
}
if (props.conversationType === 'unattended') {
if (props.conversationType === wootConstants.CONVERSATION_TYPE.UNATTENDED) {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
@@ -311,12 +315,30 @@ const pageTitle = computed(() => {
return t('CHAT_LIST.TAB_HEADING');
});
function filterByAssigneeTab(conversations) {
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.ME) {
return conversations.filter(
c => c.meta?.assignee?.id === currentUser.value?.id
);
}
if (activeAssigneeTab.value === wootConstants.ASSIGNEE_TYPE.UNASSIGNED) {
return conversations.filter(c => !c.meta?.assignee);
}
return [...conversations];
}
const conversationList = computed(() => {
let localConversationList = [];
if (!hasAppliedFiltersOrActiveFolders.value) {
const filters = conversationFilters.value;
if (activeAssigneeTab.value === 'me') {
if (
props.conversationType === wootConstants.CONVERSATION_TYPE.PARTICIPATING
) {
localConversationList = filterByAssigneeTab(
participatingChatsList.value(filters)
);
} else if (activeAssigneeTab.value === 'me') {
localConversationList = [...mineChatsList.value(filters)];
} else if (activeAssigneeTab.value === 'unassigned') {
localConversationList = [...unAssignedChatsList.value(filters)];
@@ -637,9 +659,11 @@ function redirectToConversationList() {
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
conversationType = wootConstants.CONVERSATION_TYPE.MENTION;
} else if (isOnParticipatingView({ route: { name } })) {
conversationType = wootConstants.CONVERSATION_TYPE.PARTICIPATING;
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
conversationType = wootConstants.CONVERSATION_TYPE.UNATTENDED;
}
router.push(
conversationListPageURL({

View File

@@ -27,10 +27,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -208,10 +204,7 @@ onMounted(() => {
<template>
<div class="space-y-2 mb-4">
<div
class="overflow-y-auto"
:class="{ 'max-h-96': isPopout, 'max-h-56': !isPopout }"
>
<div class="overflow-y-auto max-h-56">
<p
v-dompurify-html="formatMessage(generatedContent, false)"
class="text-n-iris-12 text-sm prose-sm font-normal !mb-4"

View File

@@ -313,7 +313,12 @@ const plugins = computed(() => {
const sendWithSignature = computed(() => {
// this is considered the source of truth, we watch this property
// on change, we toggle the signature in the editor
if (props.allowSignature && !props.isPrivate && props.channelType) {
if (
props.allowSignature &&
!props.isPrivate &&
props.channelType &&
!props.disabled
) {
return fetchSignatureFlagFromUISettings(props.channelType);
}
@@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
}
function addSignature() {
if (props.disabled) return;
let content = props.modelValue;
// see if the content is empty, if it is before appending the signature
// we need to add a paragraph node and move the cursor at the start of the editor
@@ -454,6 +460,7 @@ function addSignature() {
}
function removeSignature() {
if (props.disabled) return;
if (!props.signature) return;
let content = props.modelValue;
content = removeSignatureHelper(
@@ -806,7 +813,7 @@ watch(
watch(sendWithSignature, newValue => {
// see if the allowSignature flag is true
if (props.allowSignature) {
if (props.allowSignature && !props.disabled) {
toggleSignatureInEditor(newValue);
}
});
@@ -977,7 +984,32 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
.ProseMirror-woot-style {
@apply overflow-auto min-h-[5rem] max-h-[7.5rem];
@apply overflow-auto;
}
.ProseMirror-woot-style:not(
:where(.resizable-editor-wrapper .ProseMirror-woot-style)
) {
@apply min-h-[5rem] max-h-[7.5rem];
}
// Resizable editor wrapper styles
.resizable-editor-wrapper {
.ProseMirror-woot-style {
min-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
max-height: clamp(
var(--editor-min-allowed, var(--editor-min-height, 5rem)),
var(--editor-height, var(--editor-min-height, 5rem)),
var(--editor-max-allowed, var(--editor-max-height, 7.5rem))
);
transition:
min-height var(--editor-height-transition, 180ms ease),
max-height var(--editor-height-transition, 180ms ease);
}
}
.ProseMirror-prompt-backdrop::backdrop {

View File

@@ -8,13 +8,22 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import SlashCommandMenu from './SlashCommandMenu.vue';
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
const SLASH_MENU_OFFSET = 4;
const createState = (
content,
placeholder,
@@ -40,6 +49,7 @@ let editorView = null;
let state;
export default {
components: { SlashCommandMenu },
mixins: [keyboardEventListenerMixins],
props: {
modelValue: { type: String, default: '' },
@@ -62,8 +72,15 @@ export default {
},
data() {
return {
plugins: [imagePastePlugin(this.handleImageUpload)],
plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
slashSearchTerm: '',
slashRange: null,
slashMenuPosition: null,
};
},
watch: {
@@ -79,7 +96,7 @@ export default {
created() {
state = createState(
this.modelValue,
this.modelValue || '',
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -95,6 +112,126 @@ export default {
}
},
methods: {
createSlashPlugin() {
return suggestionsPlugin({
matcher: triggerCharacters('/', 0),
suggestionClass: '',
onEnter: args => {
this.showSlashMenu = true;
this.slashRange = args.range;
this.slashSearchTerm = args.text || '';
this.updateSlashMenuPosition(args.range.from);
return false;
},
onChange: args => {
this.slashRange = args.range;
this.slashSearchTerm = args.text;
return false;
},
onExit: () => {
this.slashSearchTerm = '';
this.showSlashMenu = false;
this.slashMenuPosition = null;
return false;
},
onKeyDown: ({ event }) => {
return (
event.keyCode === 13 &&
this.showSlashMenu &&
this.$refs.slashMenu?.hasItems
);
},
});
},
updateSlashMenuPosition(pos) {
if (!editorView) return;
const coords = editorView.coordsAtPos(pos);
const editorRect = this.$refs.editor.getBoundingClientRect();
const isRtl = getComputedStyle(this.$refs.editor).direction === 'rtl';
this.slashMenuPosition = {
top: coords.bottom - editorRect.top + SLASH_MENU_OFFSET,
...(isRtl
? { right: editorRect.right - coords.right }
: { left: coords.left - editorRect.left }),
};
},
removeSlashTriggerText() {
if (!editorView || !this.slashRange) return;
const { from, to } = this.slashRange;
editorView.dispatch(editorView.state.tr.delete(from, to));
state = editorView.state;
},
executeSlashCommand(actionKey) {
if (!editorView) return;
this.removeSlashTriggerText();
const { schema } = editorView.state;
const commandMap = {
strong: () =>
toggleMark(schema.marks.strong)(
editorView.state,
editorView.dispatch
),
em: () =>
toggleMark(schema.marks.em)(editorView.state, editorView.dispatch),
strike: () =>
toggleMark(schema.marks.strike)(
editorView.state,
editorView.dispatch
),
code: () =>
toggleMark(schema.marks.code)(editorView.state, editorView.dispatch),
h1: () =>
toggleBlockType(schema.nodes.heading, { level: 1 })(
editorView.state,
editorView.dispatch
),
h2: () =>
toggleBlockType(schema.nodes.heading, { level: 2 })(
editorView.state,
editorView.dispatch
),
h3: () =>
toggleBlockType(schema.nodes.heading, { level: 3 })(
editorView.state,
editorView.dispatch
),
bulletList: () =>
wrapInList(schema.nodes.bullet_list)(
editorView.state,
editorView.dispatch
),
orderedList: () =>
wrapInList(schema.nodes.ordered_list)(
editorView.state,
editorView.dispatch
),
insertTable: () => {
const { table, table_row, table_header, table_cell, paragraph } =
schema.nodes;
const headerCells = [0, 1, 2].map(() =>
table_header.createAndFill(null, paragraph.create())
);
const dataCells = [0, 1, 2].map(() =>
table_cell.createAndFill(null, paragraph.create())
);
const headerRow = table_row.create(null, headerCells);
const dataRow = table_row.create(null, dataCells);
const tableNode = table.create(null, [headerRow, dataRow]);
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
};
const command = commandMap[actionKey];
if (command) {
command();
state = editorView.state;
this.emitOnChange();
editorView.focus();
}
},
contentFromEditor() {
if (editorView) {
return ArticleMarkdownSerializer.serialize(editorView.state.doc);
@@ -170,7 +307,7 @@ export default {
},
reloadState() {
state = createState(
this.modelValue,
this.modelValue || '',
this.placeholder,
this.plugins,
{ onImageUpload: this.openFileBrowser },
@@ -262,7 +399,8 @@ export default {
// Get the editor's width
const editorWidth = editor.offsetWidth;
const menubarWidth = 480; // Menubar width (adjust as needed (px))
const menubar = editor.querySelector('.ProseMirror-menubar');
const menubarWidth = menubar ? menubar.scrollWidth : 480;
// Get the end position of the selection
const { bottom: endBottom, right: endRight } = editorView.coordsAtPos(to);
@@ -290,7 +428,15 @@ export default {
<template>
<div>
<div class="editor-root editor--article">
<div class="editor-root editor--article relative">
<SlashCommandMenu
v-if="showSlashMenu"
ref="slashMenu"
:search-key="slashSearchTerm"
:enabled-menu-options="enabledMenuOptions"
:position="slashMenuPosition"
@select-action="executeSlashCommand"
/>
<input
ref="imageUploadInput"
type="file"

View File

@@ -128,7 +128,6 @@ export default {
},
},
emits: [
'replaceText',
'toggleInsertArticle',
'selectWhatsappTemplate',
'selectContentTemplate',
@@ -277,9 +276,6 @@ export default {
toggleMessageSignature() {
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
},
replaceText(text) {
this.$emit('replaceText', text);
},
toggleInsertArticle() {
this.$emit('toggleInsertArticle');
},

View File

@@ -54,7 +54,7 @@ export default {
default: undefined,
},
},
emits: ['setReplyMode', 'togglePopout', 'executeCopilotAction'],
emits: ['setReplyMode', 'toggleEditorSize', 'executeCopilotAction'],
setup(props, { emit }) {
const setReplyMode = mode => {
emit('setReplyMode', mode);
@@ -189,7 +189,7 @@ export default {
class="text-n-slate-11"
sm
icon="i-lucide-maximize-2"
@click="$emit('togglePopout')"
@click="$emit('toggleEditorSize')"
/>
</div>
</div>

View File

@@ -0,0 +1,180 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
enabledMenuOptions: {
type: Array,
default: () => [],
},
position: {
type: Object,
default: null,
},
});
const emit = defineEmits(['selectAction']);
const { t } = useI18n();
const EDITOR_ACTIONS = [
{
value: 'h1',
labelKey: 'SLASH_COMMANDS.HEADING_1',
icon: 'i-lucide-heading-1',
menuKey: 'h1',
},
{
value: 'h2',
labelKey: 'SLASH_COMMANDS.HEADING_2',
icon: 'i-lucide-heading-2',
menuKey: 'h2',
},
{
value: 'h3',
labelKey: 'SLASH_COMMANDS.HEADING_3',
icon: 'i-lucide-heading-3',
menuKey: 'h3',
},
{
value: 'strong',
labelKey: 'SLASH_COMMANDS.BOLD',
icon: 'i-lucide-bold',
menuKey: 'strong',
},
{
value: 'em',
labelKey: 'SLASH_COMMANDS.ITALIC',
icon: 'i-lucide-italic',
menuKey: 'em',
},
{
value: 'insertTable',
labelKey: 'SLASH_COMMANDS.TABLE',
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
icon: 'i-lucide-strikethrough',
menuKey: 'strike',
},
{
value: 'code',
labelKey: 'SLASH_COMMANDS.CODE',
icon: 'i-lucide-code',
menuKey: 'code',
},
{
value: 'bulletList',
labelKey: 'SLASH_COMMANDS.BULLET_LIST',
icon: 'i-lucide-list',
menuKey: 'bulletList',
},
{
value: 'orderedList',
labelKey: 'SLASH_COMMANDS.ORDERED_LIST',
icon: 'i-lucide-list-ordered',
menuKey: 'orderedList',
},
];
const listContainerRef = ref(null);
const selectedIndex = ref(0);
const items = computed(() => {
const search = props.searchKey.toLowerCase();
return EDITOR_ACTIONS.filter(action => {
if (!props.enabledMenuOptions.includes(action.menuKey)) return false;
if (!search) return true;
return t(action.labelKey).toLowerCase().includes(search);
});
});
const hasItems = computed(() => items.value.length > 0);
const menuStyle = computed(() => {
if (!props.position) return {};
const style = { top: `${props.position.top}px` };
if (props.position.right != null) {
style.right = `${props.position.right}px`;
} else {
style.left = `${props.position.left}px`;
}
return style;
});
const adjustScroll = () => {
nextTick(() => {
const container = listContainerRef.value;
if (!container) return;
const el = container.querySelector(`#slash-item-${selectedIndex.value}`);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
};
const onSelect = () => {
const item = items.value[selectedIndex.value];
if (item) emit('selectAction', item.value);
};
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
// Reset selection when filtered items change
watch(items, () => {
selectedIndex.value = 0;
});
const onHover = index => {
selectedIndex.value = index;
};
const onItemClick = index => {
selectedIndex.value = index;
onSelect();
};
defineExpose({ hasItems });
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div
v-if="hasItems"
ref="listContainerRef"
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[10rem] shadow-lg p-2 overflow-auto max-h-[15rem]"
:style="menuStyle"
>
<button
v-for="(item, index) in items"
:id="`slash-item-${index}`"
:key="item.value"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 border-0 rounded-lg text-n-slate-12 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-1 dark:bg-n-alpha-2': index === selectedIndex,
}"
@mouseover="onHover(index)"
@click="onItemClick(index)"
>
<Icon :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span class="min-w-0 text-sm truncate">
{{ t(item.labelKey) }}
</span>
</button>
</div>
</template>

View File

@@ -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 PriorityMark from './PriorityMark.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.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-center gap-2 flex-shrink-0"
class="flex items-baseline gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
@@ -317,7 +317,10 @@ const deleteConversation = () => {
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
</span>
<PriorityMark :priority="chat.priority" class="flex-shrink-0" />
<CardPriorityIcon
:priority="chat.priority"
class="flex-shrink-0 !size-3.5"
/>
</div>
</div>
<h4

View File

@@ -45,6 +45,7 @@ const backButtonUrl = computed(() => {
const conversationTypeMap = {
conversation_through_mentions: 'mention',
conversation_through_participating: 'participating',
conversation_through_unattended: 'unattended',
};
return conversationListPageURL({

View File

@@ -16,10 +16,6 @@ defineProps({
type: String,
default: '',
},
isPopout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
@@ -69,7 +65,6 @@ const onSend = () => {
:generated-content="generatedContent"
:min-height="4"
:enabled-menu-options="[]"
:is-popout="isPopout"
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"

View File

@@ -1,7 +1,7 @@
<script>
import { ref, provide } from 'vue';
import { ref, provide, useTemplateRef } from 'vue';
import { useElementSize } from '@vueuse/core';
// composable
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useLabelSuggestions } from 'dashboard/composables/useLabelSuggestions';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
@@ -11,6 +11,7 @@ import MessageList from 'next/message/MessageList.vue';
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
import Banner from 'dashboard/components/ui/Banner.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ResizableEditorWrapper from './ResizableEditorWrapper.vue';
// stores and apis
import { mapGetters } from 'vuex';
@@ -43,21 +44,16 @@ export default {
Banner,
ConversationLabelSuggestion,
Spinner,
ResizableEditorWrapper,
},
mixins: [inboxMixin],
setup() {
const isPopOutReplyBox = ref(false);
const conversationPanelRef = ref(null);
const keyboardEvents = {
Escape: {
action: () => {
isPopOutReplyBox.value = false;
},
},
};
useKeyboardEvents(keyboardEvents);
const resizableEditorWrapperRef = ref(null);
const messagesViewRef = useTemplateRef('messagesViewRef');
const topBannerRef = useTemplateRef('topBannerRef');
const { height: containerHeight } = useElementSize(messagesViewRef);
const { height: topBannerHeight } = useElementSize(topBannerRef);
const {
captainTasksEnabled,
@@ -68,11 +64,15 @@ export default {
provide('contextMenuElementTarget', conversationPanelRef);
return {
isPopOutReplyBox,
captainTasksEnabled,
getLabelSuggestions,
isLabelSuggestionFeatureEnabled,
conversationPanelRef,
resizableEditorWrapperRef,
messagesViewRef,
topBannerRef,
containerHeight,
topBannerHeight,
};
},
data() {
@@ -254,6 +254,7 @@ export default {
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.messageSentSinceOpened = false;
this.resetReplyEditorHeight();
},
},
@@ -437,26 +438,37 @@ export default {
const payload = useSnakeCase(message);
await this.$store.dispatch('sendMessageWithData', payload);
},
toggleReplyEditorSize() {
this.resizableEditorWrapperRef?.toggleEditorExpand?.();
},
resetReplyEditorHeight() {
this.resizableEditorWrapperRef?.resetEditorHeight?.();
},
},
};
</script>
<template>
<div class="flex flex-col justify-between flex-grow h-full min-w-0 m-0">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
<div
ref="messagesViewRef"
class="flex flex-col justify-between flex-grow h-full min-w-0 m-0"
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
:href-link="replyWindowLink"
:href-link-text="replyWindowLinkText"
/>
<Banner
v-else-if="hasDuplicateInstagramInbox"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
/>
</div>
<MessageList
ref="conversationPanelRef"
class="conversation-panel flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4"
@@ -498,13 +510,7 @@ export default {
/>
</template>
</MessageList>
<div
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
}"
>
<div class="flex relative flex-col bg-n-surface-1">
<div
v-if="isAnyoneTyping"
class="absolute flex items-center w-full h-0 -top-7"
@@ -520,42 +526,12 @@ export default {
/>
</div>
</div>
<ReplyBox
:pop-out-reply-box="isPopOutReplyBox"
@update:pop-out-reply-box="isPopOutReplyBox = $event"
/>
<ResizableEditorWrapper
ref="resizableEditorWrapperRef"
:container-height="Math.max(0, containerHeight - topBannerHeight)"
>
<ReplyBox @toggle-editor-size="toggleReplyEditorSize" />
</ResizableEditorWrapper>
</div>
</div>
</template>
<style scoped lang="scss">
.modal-mask {
@apply fixed;
&::v-deep {
.ProseMirror-woot-style {
@apply max-h-[25rem];
}
.reply-box {
@apply border border-n-weak max-w-[75rem] w-[70%];
&.is-private {
@apply dark:border-n-amber-3/30 border-n-amber-12/5;
}
}
.reply-box .reply-box__top {
@apply relative min-h-[27.5rem];
}
.reply-box__top .input {
@apply min-h-[27.5rem];
}
.emoji-dialog {
@apply absolute ltr:left-auto rtl:right-auto bottom-1;
}
}
}
</style>

View File

@@ -1,53 +0,0 @@
<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>

View File

@@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import {
getMessageVariables,
getUndefinedVariablesInMessage,
replaceVariablesInMessage,
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
@@ -82,13 +81,7 @@ export default {
CopilotReplyBottomPanel,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
popOutReplyBox: {
type: Boolean,
default: false,
},
},
emits: ['update:popOutReplyBox'],
emits: ['toggleEditorSize'],
setup() {
const {
uiSettings,
@@ -253,6 +246,9 @@ 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;
}
@@ -545,7 +541,10 @@ export default {
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
this.copilotAcceptedMessages[key] = trimContent(
message || '',
this.maxLength
);
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -603,7 +602,7 @@ export default {
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
const draftToSave = trimContent(this.message || '', this.maxLength);
this.$store.dispatch('draftMessages/set', {
key,
@@ -630,10 +629,17 @@ export default {
return message;
}
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
// still normalize stale signatures out of drafts when signature is off.
if (this.isEditorDisabled && this.sendWithSignature) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
@@ -785,7 +791,6 @@ export default {
this.clearMessage();
this.hideEmojiPicker();
this.$emit('update:popOutReplyBox', false);
}
},
sendMessageAsMultipleMessages(message, copilotAcceptedMessage = '') {
@@ -905,32 +910,6 @@ export default {
});
this.hideContentTemplatesModal();
},
replaceText(message) {
if (this.sendWithSignature && !this.private) {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
const updatedMessage = replaceVariablesInMessage({
message,
variables: this.messageVariables,
});
setTimeout(() => {
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
this.message = updatedMessage;
}, 100);
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
// Clear attachments when switching between private note and reply modes
// This is to prevent from breaking the upload rules
@@ -1231,8 +1210,9 @@ export default {
file => !file?.isRecordedAudio
);
},
togglePopout() {
this.$emit('update:popOutReplyBox', !this.popOutReplyBox);
toggleEditorSize() {
this.$emit('toggleEditorSize');
this.$nextTick(() => this.messageEditor?.focusEditorInputField());
},
onSubmitCopilotReply() {
const acceptedMessage = this.copilot.accept();
@@ -1258,9 +1238,8 @@ export default {
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
:characters-remaining="charactersRemaining"
:editor-content="message"
:popout-reply-box="popOutReplyBox"
@set-reply-mode="setReplyMode"
@toggle-popout="togglePopout"
@toggle-editor-size="toggleEditorSize"
@toggle-copilot="copilot.toggleEditor"
@execute-copilot-action="executeCopilotAction"
/>
@@ -1289,7 +1268,7 @@ export default {
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout || popOutReplyBox,
'emoji-dialog--expanded': isOnExpandedLayout,
}"
:on-click="addIntoEditor"
/>
@@ -1313,7 +1292,6 @@ export default {
:show-copilot-editor="copilot.showEditor.value"
:is-generating-content="copilot.isGenerating.value"
:generated-content="copilot.generatedContent.value"
:is-popout="popOutReplyBox"
:placeholder="$t('CONVERSATION.FOOTER.COPILOT_MSG_INPUT')"
@focus="onFocus"
@blur="onBlur"
@@ -1429,7 +1407,6 @@ export default {
:new-conversation-modal-active="newConversationModalActive"
@select-whatsapp-template="openWhatsappTemplateModal"
@select-content-template="openContentTemplateModal"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>

View File

@@ -0,0 +1,154 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, useTemplateRef } from 'vue';
import { useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const props = defineProps({
containerHeight: { type: Number, default: 0 },
});
const DEFAULT_HEIGHT = 120;
const MIN_HEIGHT = 80;
const MIN_MESSAGES_HEIGHT = 200;
const EXPAND_RATIO = 0.5;
const RESET_DELAY_MS = 120;
const wrapperRef = useTemplateRef('wrapperRef');
const surroundingHeight = ref(0);
const editorHeight = ref(DEFAULT_HEIGHT);
const isResizing = ref(false);
const startY = ref(0);
const startHeight = ref(0);
let resetTimeoutId = null;
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
// Measure height of elements surrounding the editor (top panel, email fields, bottom panel)
const measureSurroundingHeight = () => {
if (wrapperRef.value) {
surroundingHeight.value = Math.max(
0,
wrapperRef.value.offsetHeight - editorHeight.value
);
}
};
const isContainerReady = computed(() => props.containerHeight > 0);
const sizeBounds = computed(() => {
const h = props.containerHeight;
const s = surroundingHeight.value;
const max = Math.max(MIN_HEIGHT, h - MIN_MESSAGES_HEIGHT - s);
const expanded = clamp(Math.floor(h * EXPAND_RATIO - s / 2), MIN_HEIGHT, max);
return {
min: MIN_HEIGHT,
max: isContainerReady.value ? max : DEFAULT_HEIGHT,
expanded,
default: clamp(DEFAULT_HEIGHT, MIN_HEIGHT, max),
};
});
const clampToBounds = val =>
clamp(val, sizeBounds.value.min, sizeBounds.value.max);
const clearDragStyles = () => {
Object.assign(document.body.style, { cursor: '', userSelect: '' });
};
const getClientY = e => (e.touches ? e.touches[0].clientY : e.clientY);
const onResizeStart = event => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
isResizing.value = true;
startY.value = getClientY(event);
startHeight.value = clampToBounds(editorHeight.value);
editorHeight.value = startHeight.value;
Object.assign(document.body.style, {
cursor: 'row-resize',
userSelect: 'none',
});
};
const onResizeMove = event => {
if (!isResizing.value) return;
if (event.touches) event.preventDefault();
editorHeight.value = clampToBounds(
startHeight.value + startY.value - getClientY(event)
);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
clearDragStyles();
};
const resetEditorHeight = () => {
editorHeight.value = sizeBounds.value.default;
};
const toggleEditorExpand = () => {
editorHeight.value = clampToBounds(editorHeight.value);
measureSurroundingHeight();
const { expanded, max, default: defaultHeight } = sizeBounds.value;
const isExpanded = editorHeight.value > defaultHeight;
// If expanded is too close to default, use max so the toggle is always noticeable
const target = expanded - defaultHeight < 100 ? max : expanded;
editorHeight.value = isExpanded ? defaultHeight : target;
};
const handleMessageSent = () => {
clearTimeout(resetTimeoutId);
resetTimeoutId = setTimeout(resetEditorHeight, RESET_DELAY_MS);
};
onMounted(() => {
emitter.on(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
});
onBeforeUnmount(() => {
emitter.off(BUS_EVENTS.MESSAGE_SENT, handleMessageSent);
clearTimeout(resetTimeoutId);
if (isResizing.value) {
isResizing.value = false;
clearDragStyles();
}
});
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
useEventListener(document, 'touchcancel', onResizeEnd);
useEventListener(window, 'blur', onResizeEnd);
defineExpose({ toggleEditorExpand, resetEditorHeight });
</script>
<template>
<div
ref="wrapperRef"
class="relative resizable-editor-wrapper"
:style="{
'--editor-height': editorHeight + 'px',
'--editor-min-allowed': sizeBounds.min + 'px',
'--editor-max-allowed': sizeBounds.max + 'px',
'--editor-height-transition': isResizing ? 'none' : '180ms ease',
}"
>
<div
class="group absolute inset-x-0 -top-4 z-10 flex h-4 cursor-row-resize select-none items-center justify-center bg-gradient-to-b from-transparent from-10% dark:to-n-surface-1/80 to-n-surface-1/90 backdrop-blur-[0.01875rem]"
@mousedown="onResizeStart"
@touchstart.prevent="onResizeStart"
@dblclick="resetEditorHeight"
>
<div
class="w-8 h-0.5 mt-1 rounded-full bg-n-slate-6 group-hover:bg-n-slate-8 transition-all duration-200 motion-safe:group-hover:animate-bounce"
:class="{ 'bg-n-slate-8 animate-bounce': isResizing }"
/>
</div>
<slot />
</div>
</template>

View File

@@ -8,6 +8,7 @@ import {
agents,
teams,
labels,
booleanFilterOptions,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
@@ -73,6 +74,8 @@ describe('useAutomation', () => {
return countries;
case 'message_type':
return messageTypeOptions;
case 'private_note':
return booleanFilterOptions;
case 'priority':
return priorityOptions;
default:
@@ -89,7 +92,9 @@ describe('useAutomation', () => {
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
return options.addNoneToListFn
? options.addNoneToListFn(options.agents)
: options.agents;
case 'send_email_to_team':
return teams;
case 'send_message':
@@ -226,6 +231,9 @@ describe('useAutomation', () => {
expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions
);
expect(getConditionDropdownValues('private_note')).toEqual(
booleanFilterOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
@@ -234,7 +242,11 @@ describe('useAutomation', () => {
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'last_responding_agent', name: 'AUTOMATION.LAST_RESPONDING_AGENT' },
...agents,
]);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);

View File

@@ -0,0 +1,98 @@
import { useEditableAutomation } from '../useEditableAutomation';
import useAutomationValues from '../useAutomationValues';
vi.mock('../useAutomationValues');
describe('useEditableAutomation', () => {
beforeEach(() => {
useAutomationValues.mockReturnValue({
getConditionDropdownValues: vi.fn(attributeKey => {
if (attributeKey === 'private_note') {
return [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
}
return [];
}),
getActionDropdownValues: vi.fn(actionName => {
if (actionName === 'assign_agent') {
return [
{ id: 'nil', name: 'None' },
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
{ id: 1, name: 'Agent 1' },
];
}
return [];
}),
});
});
it('rehydrates boolean conditions as a single selected option', () => {
const automation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: [false],
query_operator: null,
},
],
actions: [],
};
const automationTypes = {
message_created: {
conditions: [{ key: 'private_note', inputType: 'search_select' }],
},
};
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], automationTypes, []);
expect(result.conditions).toEqual([
{
attribute_key: 'private_note',
filter_operator: 'equal_to',
values: { id: false, name: 'False' },
query_operator: 'and',
},
]);
});
it('rehydrates last responding agent as a selected action option', () => {
const automation = {
event_name: 'conversation_created',
conditions: [],
actions: [
{
action_name: 'assign_agent',
action_params: ['last_responding_agent'],
},
],
};
const automationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
const { formatAutomation } = useEditableAutomation();
const result = formatAutomation(automation, [], {}, automationActionTypes);
expect(result.actions).toEqual([
{
action_name: 'assign_agent',
action_params: [
{
id: 'last_responding_agent',
name: 'Last Responding Agent',
},
],
},
]);
});
});

View File

@@ -119,24 +119,30 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
mockTeams.length + 1
); // +1 for "None"
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
mockAgents.length + 2
); // +2 for "None" and "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
it('returns teams with "None" option for assign_team and teams only for send_email_to_team', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
const assignTeamResult = getMacroDropdownValues('assign_team');
expect(assignTeamResult[0]).toEqual({
id: 'nil',
name: 'AUTOMATION.NONE_OPTION',
});
expect(assignTeamResult.slice(1)).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
it('returns agents with "None" and "Self" options for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
expect(result[0]).toEqual({ id: 'nil', name: 'AUTOMATION.NONE_OPTION' });
expect(result[1]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(2)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
@@ -172,8 +178,11 @@ describe('useMacros', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'nil', name: 'AUTOMATION.NONE_OPTION' },
{ id: 'self', name: 'Self' },
]);
});

View File

@@ -121,8 +121,19 @@ export default function useAutomationValues() {
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
let agentsList = agents.value;
if (type === 'assign_agent') {
agentsList = [
{
id: 'last_responding_agent',
name: t('AUTOMATION.LAST_RESPONDING_AGENT'),
},
...agentsList,
];
}
return getActionOptions({
agents: agents.value,
agents: agentsList,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,

View File

@@ -46,11 +46,26 @@ export function useEditableAutomation() {
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
const dropdownValues = getConditionDropdownValues(
condition.attribute_key
);
const hasBooleanOptions =
inputType === 'search_select' &&
dropdownValues.length &&
dropdownValues.every(item => typeof item.id === 'boolean');
if (hasBooleanOptions) {
return {
...condition,
query_operator: condition.query_operator || 'and',
values: dropdownValues.find(item => item.id === condition.values[0]),
};
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
values: [...dropdownValues].filter(item =>
[...condition.values].includes(item.id)
),
};
});

View File

@@ -12,11 +12,14 @@ import { useKeyboardEvents } from './useKeyboardEvents';
/**
* Wrap the action in a function that calls the action and prevents the default event behavior.
* Only prevents default when items are available to navigate.
* @param {Function} action - The action to be called.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/
const createAction = action => ({
const createAction = (action, items) => ({
action: e => {
if (!items.value?.length) return;
action();
e.preventDefault();
},
@@ -38,15 +41,14 @@ const createKeyboardEvents = (
items
) => {
const events = {
ArrowUp: createAction(moveSelectionUp),
'Control+KeyP': createAction(moveSelectionUp),
ArrowDown: createAction(moveSelectionDown),
'Control+KeyN': createAction(moveSelectionDown),
ArrowUp: createAction(moveSelectionUp, items),
'Control+KeyP': createAction(moveSelectionUp, items),
ArrowDown: createAction(moveSelectionDown, items),
'Control+KeyN': createAction(moveSelectionDown, items),
};
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
events.Enter = createAction(onSelect, items);
}
return events;

View File

@@ -15,6 +15,11 @@ export const useMacros = () => {
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
const withNoneOption = options => [
{ id: 'nil', name: t('AUTOMATION.NONE_OPTION') },
...(options || []),
];
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
@@ -23,10 +28,15 @@ export const useMacros = () => {
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
return withNoneOption(teams.value);
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
return [
...withNoneOption(),
{ id: 'self', name: 'Self' },
...agents.value,
];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({

View File

@@ -172,6 +172,7 @@ export const FORMATTING = {
export const ARTICLE_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'strike',
'link',
'undo',
'redo',
@@ -182,6 +183,7 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'h3',
'imageUpload',
'code',
'insertTable',
];
/**

View File

@@ -12,6 +12,11 @@ export default {
SNOOZED: 'snoozed',
ALL: 'all',
},
CONVERSATION_TYPE: {
MENTION: 'mention',
PARTICIPATING: 'participating',
UNATTENDED: 'unattended',
},
SORT_BY_TYPE: {
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',

View File

@@ -37,6 +37,7 @@ 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',
@@ -49,6 +50,7 @@ export const FEATURE_FLAGS = {
export const PREMIUM_FEATURES = [
FEATURE_FLAGS.SLA,
FEATURE_FLAGS.CAPTAIN,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,

View File

@@ -51,6 +51,7 @@ export const conversationListPageURL = ({
} else if (conversationType) {
const urlMap = {
mention: 'mentions/conversations',
participating: 'participating/conversations',
unattended: 'unattended/conversations',
};
url = `accounts/${accountId}/${urlMap[conversationType]}`;

View File

@@ -150,6 +150,7 @@ export const getConditionOptions = ({
conversation_language: languages,
country_code: countries,
message_type: messageTypeOptions,
private_note: booleanFilterOptions,
priority: priorityOptions,
labels: generateConditionOptions(labels, 'title'),
};

View File

@@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
.trim(); // Trim any extra space
}
/**
* Removes inline base64 markdown images from signature content.
*
* @param {string} content
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
*/
export function stripInlineBase64Images(content) {
if (!content || typeof content !== 'string') {
return { sanitizedContent: content || '', hasInlineImages: false };
}
const markdownInlineBase64ImageRegex =
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
const hasInlineImages = sanitizedContent !== content;
return { sanitizedContent, hasInlineImages };
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.

View File

@@ -40,6 +40,15 @@ describe('#URL Helpers', () => {
'/app/accounts/1/custom_view/1'
);
});
it('should return url to participating conversations', () => {
expect(
conversationListPageURL({
accountId: 1,
conversationType: 'participating',
})
).toBe('/app/accounts/1/participating/conversations');
});
});
describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => {

View File

@@ -178,6 +178,21 @@ describe('getConditionOptions', () => {
})
).toEqual(testOptions);
});
it('returns boolean options for private_note', () => {
const booleanOptions = [
{ id: true, name: 'True' },
{ id: false, name: 'False' },
];
expect(
helpers.getConditionOptions({
booleanFilterOptions: booleanOptions,
customAttributes,
type: 'private_note',
})
).toEqual(booleanOptions);
});
});
describe('getFileName', () => {

View File

@@ -15,6 +15,7 @@ import {
getMenuAnchor,
calculateMenuPosition,
stripUnsupportedFormatting,
stripInlineBase64Images,
} from '../editorHelper';
import { FORMATTING } from 'dashboard/constants/editor';
import { EditorState } from '@chatwoot/prosemirror-schema';
@@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => {
});
});
describe('stripInlineBase64Images', () => {
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
const content =
'Hello\n![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\nWorld';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(true);
expect(sanitizedContent).not.toContain('data:image/png;base64');
expect(sanitizedContent).toContain('Hello');
expect(sanitizedContent).toContain('World');
});
it('leaves hosted image markdown unchanged', () => {
const content = '![](https://example.com/logo.png)';
const { sanitizedContent, hasInlineImages } =
stripInlineBase64Images(content);
expect(hasInlineImages).toBe(false);
expect(sanitizedContent).toBe(content);
});
it('returns empty hasInlineImages for empty input', () => {
expect(stripInlineBase64Images('')).toEqual({
sanitizedContent: '',
hasInlineImages: false,
});
});
});
describe('insertAtCursor', () => {
it('should return undefined if editorView is not provided', () => {
const result = insertAtCursor(undefined, schema.text('Hello'), 0);

View File

@@ -45,6 +45,10 @@ describe('#resolveTeamIds', () => {
const resolvedTeams = '⚙️ sales team, 🤷‍♂️ fayaz';
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
});
it('resolves nil as None', () => {
expect(resolveTeamIds(teams, ['nil'])).toEqual('None');
});
});
describe('#resolveLabels', () => {
@@ -59,6 +63,10 @@ describe('#resolveAgents', () => {
const resolvedAgents = 'John Doe';
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
});
it('resolves nil and self values', () => {
expect(resolveAgents(agents, ['nil', 'self'])).toEqual('None, Self');
});
});
describe('#getFileName', () => {

View File

@@ -125,6 +125,7 @@ const validateSingleAction = action => {
'mute_conversation',
'snooze_conversation',
'resolve_conversation',
'remove_assigned_agent',
'remove_assigned_team',
'open_conversation',
'pending_conversation',

View File

@@ -63,6 +63,16 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
"COPY_SUCCESS": "ምስጢሩ ወደ ክሊፕቦርድ ተቀድሷል",
"TOGGLE": "የምስጢሩን ማየት አሳይ/ደብቅ",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "ተጠናቀቀ",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",

View File

@@ -140,6 +140,8 @@
"ACTIONS": {
"ASSIGN_AGENT": "Assign to Agent",
"ASSIGN_TEAM": "Assign a Team",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label",
"SEND_EMAIL_TO_TEAM": "Send an Email to Team",
@@ -169,6 +171,7 @@
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "የግል ማስታወሻ",
"MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "Email",
"INBOX": "Inbox",

View File

@@ -52,5 +52,17 @@
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
},
"SLASH_COMMANDS": {
"HEADING_1": "Heading 1",
"HEADING_2": "Heading 2",
"HEADING_3": "Heading 3",
"BOLD": "Bold",
"ITALIC": "Italic",
"STRIKETHROUGH": "Strikethrough",
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
}
}

View File

@@ -20,6 +20,7 @@
"CALL": "ደውል",
"CALL_INITIATED": "ለእውነተኛው እውቀት መደወል እየተከናወነ ነው…",
"CALL_FAILED": "ጥሪውን መጀመር አልቻልንም። እባክዎ ደግመው ይሞክሩ።.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "የድምፅ ኢንቦክስ ይምረጡ"
},

View File

@@ -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": "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",
"NO_MESSAGE_1": "የደንበኞች መልእክቶች በኢንቦክስዎ አልተገኙም።",
"NO_MESSAGE_2": " ወደ ገፅዎ መልእክት ለመላክ!",
"NO_INBOX_1": "እሺ! አሁን ምንም ኢንቦክስ አልጨመሩም።",
"NO_INBOX_2": " ለመጀመር",
"NO_INBOX_AGENT": "ወይ! ምንም ኢንቦክስ አባል አይደለህም። እባክዎ አስተዳዳሪዎን ያነጋግሩ",
"SEARCH_MESSAGES": "መልእክቶችን በውይይቶች ውስጥ ይፈልጉ",
"VIEW_ORIGINAL": "View original",
"VIEW_TRANSLATED": "View translated",
"EMPTY_STATE": {
@@ -19,19 +19,19 @@
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
},
"SEARCH": {
"TITLE": "Search messages",
"TITLE": "መልእክቶችን ይፈልጉ",
"RESULT_TITLE": "Search Results",
"LOADING_MESSAGE": "Crunching data...",
"LOADING_MESSAGE": "መረጃ በማስተናገድ ላይ...",
"PLACEHOLDER": "Type any text to search messages",
"NO_MATCHING_RESULTS": "No results found."
},
"UNREAD_MESSAGES": "Unread Messages",
"UNREAD_MESSAGE": "Unread Message",
"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",
"CLICK_HERE": "እዚህ ጠቅ ያድርጉ",
"LOADING_INBOXES": "ኢንቦክሶች በመጫን ላይ",
"LOADING_CONVERSATIONS": "ውይይቶች በመጫን ላይ",
"CANNOT_REPLY": "ምክንያቱን በመነሳት መልስ ማድረግ አይችሉም",
"24_HOURS_WINDOW": "24 ሰዓት መልእክት ጊዜ ገደብ",
"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": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
"REPLYING_TO": "ለዚህ ትመልሳለህ፦",
"REMOVE_SELECTION": "ምርጫ አስወግድ",
"DOWNLOAD": "አውርድ",
"UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save Contact",
"NO_CONTENT": "No content to display",
@@ -85,13 +85,13 @@
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"RESOLVE_ACTION": "ተፈትኗል",
"REOPEN_ACTION": "እንደገና ክፈት",
"OPEN_ACTION": "Open",
"MORE_ACTIONS": "ተጨማሪ እርምጃዎች",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
"OPEN": "ተጨማሪ",
"CLOSE": "ዝጋ",
"DETAILS": "ዝርዝሮች",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
@@ -188,21 +188,21 @@
"MESSAGE_SIGN_TOOLTIP": "Message signature",
"ENABLE_SIGN_TOOLTIP": "Enable signature",
"DISABLE_SIGN_TOOLTIP": "Disable signature",
"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",
"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",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"COPILOT_MSG_INPUT": "ኮፒሎት ተጨማሪ እባብነቶች ስጡው, ወይም ሌላ ማንኛውንም ጥያቄ ያቀርቡ... ተከትሎ ለማስተላለፊያ ኤንተር ይጫኑ።",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
"REPLYBOX": {
"REPLY": "Reply",
"PRIVATE_NOTE": "Private Note",
"SEND": "Send",
"CREATE": "Add Note",
"REPLY": "መልስ",
"PRIVATE_NOTE": "የግል ማስታወሻ",
"SEND": "ላክ",
"CREATE": "ማስታወሻ አክል",
"INSERT_READ_MORE": "Read more",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
@@ -214,7 +214,7 @@
"DRAG_DROP": "Drag and drop here to attach",
"START_AUDIO_RECORDING": "Start audio recording",
"STOP_AUDIO_RECORDING": "Stop audio recording",
"COPILOT_THINKING": "Copilot is thinking",
"COPILOT_THINKING": "ኮፒሎት እየሰማራ ነው",
"EMAIL_HEAD": {
"TO": "TO",
"ADD_BCC": "Add bcc",
@@ -245,10 +245,10 @@
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
"CHANGE_STATUS": "Conversation status changed",
"VISIBLE_TO_AGENTS": "የግል ማስታወሻ፡ ለአንተና ቡድንህ ብቻ ይታያል",
"CHANGE_STATUS": "የውይይቱ ሁኔታ ተቀይሯል",
"CHANGE_STATUS_FAILED": "Conversation status change failed",
"CHANGE_AGENT": "Conversation Assignee changed",
"CHANGE_AGENT": "የውይይቱ ተመድብ ተቀይሯል",
"CHANGE_AGENT_FAILED": "Assignee change failed",
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed",
@@ -300,20 +300,20 @@
}
},
"EMAIL_TRANSCRIPT": {
"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",
"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.",
"FORM": {
"SEND_TO_CONTACT": "Send the transcript to the customer",
"SEND_TO_CONTACT": "መግለጫውን ለደንበኛው ይላኩ",
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
"SEND_TO_OTHER_EMAIL_ADDRESS": "Send the transcript to another email address",
"SEND_TO_OTHER_EMAIL_ADDRESS": "መግለጫውን ወደ ሌላ ኢሜይል አድራሻ ይላኩ",
"EMAIL": {
"PLACEHOLDER": "Enter an email address",
"ERROR": "Please enter a valid email address"
"PLACEHOLDER": "ኢሜይል አድራሻ ያስገቡ",
"ERROR": "ትክክለኛ ኢሜይል አድራሻ ያስገቡ"
}
}
},

View File

@@ -316,6 +316,18 @@
"SUCCESS_MESSAGE": "ቋንቋው ከፖርታል በተሳካ ሁኔታ ተሰርዟል",
"ERROR_MESSAGE": "ከፖርታሉ ቋንቋ ማስወገድ አልተቻለም። እባክዎ ደግመው ይሞክሩ።."
}
},
"DRAFT_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale moved to draft successfully",
"ERROR_MESSAGE": "Unable to move locale to draft. Try again."
}
},
"PUBLISH_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale published successfully",
"ERROR_MESSAGE": "Unable to publish locale. Try again."
}
}
},
"TABLE": {
@@ -644,8 +656,11 @@
"ARTICLES_COUNT": "{count} ጽሑፍ | {count} ጽሑፎች",
"CATEGORIES_COUNT": "{count} ምድብ | {count} ምድቦች",
"DEFAULT": "ነባሪ",
"DRAFT": "እቅድ",
"DROPDOWN_MENU": {
"MAKE_DEFAULT": "እንደ ነባሪ አድርግ",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"DELETE": "ሰርዝ"
}
},
@@ -655,6 +670,13 @@
"COMBOBOX": {
"PLACEHOLDER": "ቋንቋ ይምረጡ..."
},
"STATUS": {
"LABEL": "Status",
"OPTIONS": {
"LIVE": "ተለቀቀ",
"DRAFT": "እቅድ"
}
},
"API": {
"SUCCESS_MESSAGE": "ቋንቋ በተሳካ ሁኔታ ተጨምሯል",
"ERROR_MESSAGE": "ቋንቋውን ማክሰኞ አልተቻለም። እባክዎ ደግመው ይሞክሩ።."

View File

@@ -11,7 +11,7 @@
"WHATSAPP_REGISTRATION_INCOMPLETE": "የWhatsApp ንግድ ምዝገባዎ አልተጠናቀቀም። እባክዎ ከመገናኛ ባለሥልጣን ጋር በMeta Business Manager ውስጥ የሚታይ ስም ሁኔታዎን ያረጋግጡ።.",
"COMPLETE_REGISTRATION": "ምዝገባ አሟልት",
"LIST": {
"404": "Այս հաշվի հետ կապված մուտքային արկղեր չկան։"
"404": "ወደዚህ መለያ የተያዙ ኢንቦክሶች የሉም።"
},
"CREATE_FLOW": {
"CHANNEL": {
@@ -42,7 +42,7 @@
"PLACEHOLDER": "የድር ጣቢያ ስምዎን ያስገቡ (ለምሳሌ፡ Acme Inc)"
},
"FB": {
"HELP": "Հիշեցում․ Մուտք գործելով մենք միայն հասանելիություն ենք ստանում Ձեր էջի հաղորդագրություններին։ Ձեր անձնական հաղորդագրություններին Chatwoot-ը երբեք չի կարող հասանելիություն ունենալ։",
"HELP": "ማስታወሻ፡ በመግባት ብቻ የገጹ መልእክቶችን ብቻ እንደምንያዝ ነው። የግል መልእክቶችዎን በChatwoot ማድረስ አይቻልም።",
"CHOOSE_PAGE": "ገጽ ይምረጡ",
"CHOOSE_PLACEHOLDER": "ከዝርዝር ገጽ ይምረጡ",
"INBOX_NAME": "የኢንቦክስ ስም",
@@ -76,7 +76,7 @@
},
"WEBSITE_CHANNEL": {
"TITLE": "የድር ጣቢያ ቻናል",
"DESC": "Ստեղծեք ալիք Ձեր կայքի համար և սկսեք աջակցել Ձեր հաճախորդներին մեր կայքի վիջեթի միջոցով։",
"DESC": "ለድህረ ገጹ ቻናል ይፍጠሩ እና በድህረ ገጻችን ዊጅት ደንበኞቻችሁን ይደግፉ።",
"LOADING_MESSAGE": "የድር ጣቢያ ድጋፍ ቻናል እየተፈጠረ ነው",
"CHANNEL_AVATAR": {
"LABEL": "የቻናል ፎቶ"
@@ -86,6 +86,14 @@
"PLACEHOLDER": "እባክዎ የWebhook URLዎን ያስገቡ",
"ERROR": "እባክዎ ትክክለኛ አድራሻ ያስገቡ"
},
"CHANNEL_WEBHOOK_SECRET": {
"LABEL": "Webhook Secret",
"COPY": "ምስጢሩን ወደ ክሊፕቦርድ ቅዳ",
"COPY_SUCCESS": "ምስጢሩ ወደ ክሊፕቦርድ ተቀድሷል",
"TOGGLE": "የምስጢሩን ማየት አሳይ/ደብቅ",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"CHANNEL_DOMAIN": {
"LABEL": "የድር ጣቢያ ዶሜን",
"PLACEHOLDER": "የድር ጣቢያዎን ዶሜን ያስገቡ (ለምሳሌ፡ acme.com)"
@@ -96,11 +104,11 @@
},
"CHANNEL_WELCOME_TAGLINE": {
"LABEL": "የእንኳን ደህና መጡ መልዕክት",
"PLACEHOLDER": "Մենք հեշտացնում ենք կապվել մեզ հետ։ Հարցրեք ցանկացած բան կամ կիսվեք Ձեր կարծիքով։"
"PLACEHOLDER": "ከእኛ ጋር ቀላል መገናኘት እንደምንሠራ ነው። ማንኛውንም ጥያቄ ያቀርቡ ወይም አስተያየትዎን ያጋሩ።"
},
"CHANNEL_GREETING_MESSAGE": {
"LABEL": "የቻናል ደስታ መልእክት",
"PLACEHOLDER": "Acme Inc սովորաբար պատասխանում է մի քանի ժամվա ընթացքում։"
"PLACEHOLDER": "Acme Inc በተለምዶ በጥቂት ሰዓታት ውስጥ ይመልሳል።"
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "የቻናል ደስታ አንቀሳቅስ",
@@ -126,7 +134,7 @@
},
"TWILIO": {
"TITLE": "Twilio SMS/WhatsApp ቻናል",
"DESC": "Միացրեք Twilio-ն և սկսեք աջակցել Ձեր հաճախորդներին SMS կամ WhatsApp միջոցով։",
"DESC": "Twilio ያገናኙ እና በSMS ወይም WhatsApp ደንበኞቻችሁን ይደግፉ።",
"ACCOUNT_SID": {
"LABEL": "አካውንት SID",
"PLACEHOLDER": "እባክዎ የTwilio መለያ መለያዎን ያስገቡ",
@@ -165,12 +173,12 @@
},
"PHONE_NUMBER": {
"LABEL": "ስልክ ቁጥር",
"PLACEHOLDER": "Խնդրում ենք մուտքագրել այն հեռախոսահամարը, որտեղից կուղարկվի հաղորդագրությունը։",
"PLACEHOLDER": "ከዚህ መልእክት የሚልከው የስልክ ቁጥር እባክዎ ያስገቡ።",
"ERROR": "እባክዎ በ`+` ምልክት የሚጀምር እና ቦታ ያልያዘ ትክክለኛ የስልክ ቁጥር ያቀርቡ።."
},
"API_CALLBACK": {
"TITLE": "ካልባክ URL",
"SUBTITLE": "Twilio-ում պետք է կարգավորեք հաղորդագրության պատասխան URL-ը՝ օգտագործելով այստեղ նշված հասցեն։"
"SUBTITLE": "Twilio ውስጥ የመልእክት እንደገና መግባት አድራሻን ከዚህ በተጠቀሰው አድራሻ ጋር መቀነባበር አለብዎት።"
},
"SUBMIT_BUTTON": "የTwilio ቻናል ይፍጠሩ",
"API": {
@@ -179,7 +187,7 @@
},
"SMS": {
"TITLE": "SMS ቻናል",
"DESC": "Սկսեք աջակցել Ձեր հաճախորդներին SMS-ի միջոցով։",
"DESC": "በSMS ደንበኞቻችሁን ይደግፉ።",
"PROVIDERS": {
"LABEL": "API አቅራቢ",
"TWILIO": "Twilio",
@@ -231,7 +239,7 @@
},
"WHATSAPP": {
"TITLE": "WhatsApp ቻናል",
"DESC": "Սկսեք աջակցել Ձեր հաճախորդներին WhatsApp-ի միջոցով։",
"DESC": "በWhatsApp ደንበኞቻችሁን ይደግፉ።",
"PROVIDERS": {
"LABEL": "API አቅራቢ",
"WHATSAPP_EMBEDDED": "WhatsApp ቢዝነስ",
@@ -252,7 +260,7 @@
},
"PHONE_NUMBER": {
"LABEL": "የስልክ ቁጥር",
"PLACEHOLDER": "Խնդրում ենք մուտքագրել այն հեռախոսահամարը, որտեղից կուղարկվի հաղորդագրությունը։",
"PLACEHOLDER": "ከዚህ መልእክት የሚልከው የስልክ ቁጥር እባክዎ ያስገቡ።",
"ERROR": "እባክዎ በ`+` ምልክት የሚጀምር እና ቦታ ያልያዘ ትክክለኛ የስልክ ቁጥር ያቀርቡ።."
},
"PHONE_NUMBER_ID": {
@@ -272,9 +280,9 @@
},
"API_KEY": {
"LABEL": "API ቁልፍ",
"SUBTITLE": "Կարգավորեք WhatsApp API բանալին։",
"SUBTITLE": "WhatsApp API ቁልፍን ያቀናብሩ።",
"PLACEHOLDER": "API ቁልፍ",
"ERROR": "Խնդրում ենք մուտքագրել վավեր արժեք։"
"ERROR": "እባክዎ ትክክለኛ እሴት ያስገቡ።"
},
"API_CALLBACK": {
"TITLE": "የተመለሰ አድራሻ URL",
@@ -358,7 +366,7 @@
},
"API_CHANNEL": {
"TITLE": "የAPI ቻናል",
"DESC": "Միացրեք API ալիքը և սկսեք աջակցել Ձեր հաճախորդներին։",
"DESC": "ከAPI ቻናል ጋር ያገናኙ እና ደንበኞቻችሁን ይደግፉ።",
"CHANNEL_NAME": {
"LABEL": "የቻናል ስም",
"PLACEHOLDER": "እባክዎ የቻናል ስም ያስገቡ",
@@ -371,7 +379,7 @@
},
"SUBMIT_BUTTON": "API ቻናል ፍጠር",
"API": {
"ERROR_MESSAGE": "Չհաջողվեց պահպանել API ալիքը"
"ERROR_MESSAGE": "API ቻናሉን ማስቀመጥ አልተቻለንም"
}
},
"EMAIL_CHANNEL": {
@@ -399,7 +407,7 @@
},
"LINE_CHANNEL": {
"TITLE": "LINE ቻናል",
"DESC": "Միացրեք LINE ալիքը և սկսեք աջակցել Ձեր հաճախորդներին։",
"DESC": "ከLINE ቻናል ጋር ያገናኙ እና ደንበኞቻችሁን ይደግፉ።",
"CHANNEL_NAME": {
"LABEL": "የቻናል ስም",
"PLACEHOLDER": "እባክዎ የቻናል ስም ያስገቡ",
@@ -423,15 +431,15 @@
},
"API_CALLBACK": {
"TITLE": "የእንደገና ጥሪ አድራሻ",
"SUBTITLE": "LINE հավելվածում պետք է կարգավորեք webhook URL-ը՝ օգտագործելով այստեղ նշված հասցեն։"
"SUBTITLE": "LINE መተግበሪያ ውስጥ የwebhook አድራሻን ከዚህ በተጠቀሰው አድራሻ ጋር መቀነባበር አለብዎት።"
}
},
"TELEGRAM_CHANNEL": {
"TITLE": "Telegram ቻናል",
"DESC": "Միացրեք Telegram ալիքը և սկսեք աջակցել Ձեր հաճախորդներին։",
"DESC": "Telegram ቻናል ጋር ያገናኙ እና ደንበኞቻችሁን ይደግፉ።",
"BOT_TOKEN": {
"LABEL": "የቦት ቶክን",
"SUBTITLE": "Կարգավորեք Telegram BotFather-ից ստացած բոտի տոկենը։",
"SUBTITLE": "Telegram BotFather ያገኙትን የቦት ቶክን ያቀናብሩ።",
"PLACEHOLDER": "የቦት ቶክን"
},
"SUBMIT_BUTTON": "Telegram ቻናል ፍጠር",
@@ -493,13 +501,13 @@
},
"AGENTS": {
"TITLE": "Agent-ዎች",
"DESC": "Այստեղ կարող եք ավելացնել գործակալներ՝ նոր ստեղծված մուտքային արկղը կառավարելու համար։ Միայն այս ընտրված գործակալները կունենան մուտք դեպի Ձեր մուտքային արկղը։ Գործակալները, որոնք չեն պատկանում այս մուտքային արկղին, չեն կարողանա տեսնել կամ պատասխանել հաղորդագրություններին մուտք գործելիս։ <br> <b>Հիշեցում․</b> Որպես ադմինիստրատոր, եթե Ձեզ անհրաժեշտ է մուտք բոլոր մուտքային արկղերին, պետք է ինքներդ Ձեզ ավելացնեք որպես գործակալ բոլոր ստեղծած մուտքային արկղերում։",
"DESC": "እዚህ አዲስ የተፈጠረውን ኢንቦክስ ለመቆጣጠር ወኪሎችን ማከል ይችላሉ። እነዚህ የተመረጡ ወኪሎች ብቻ ወደ ኢንቦክስዎ መዳረሻ አላቸው። ከዚህ ኢንቦክስ አካል ያልሆኑ ወኪሎች ሲግቡ መልእክቶችን ማየት ወይም መልስ ማድረግ አይችሉም። <br> <b>ማስታወሻ፡</b> እንደ አስተዳደር ባለስልጣን ሁሉንም ኢንቦክሶች ለመዳረሻ ከፈለጉ ራስዎን እንደ ወኪል ወደ ሁሉም የሚፈጥሩት ኢንቦክሶች መጨመር አለብዎት።",
"VALIDATION_ERROR": "ከአዲሱ ኢንቦክስዎ ቢያንስ አንድ ወኪል ያክሉ",
"PICK_AGENTS": "ለኢንቦክሱ ወኪሎችን ይምረጡ"
},
"DETAILS": {
"TITLE": "የኢንቦክስ ዝርዝሮች",
"DESC": "Ընտրեք ներքևի բացվող ցանկից այն Facebook էջը, որը ցանկանում եք կապել Chatwoot-ի հետ։ Կարող եք նաև մուտքային արկղին տալ հատուկ անուն՝ ավելի լավ ճանաչման համար։"
"DESC": "ከታች ያለው ከዝርዝር ማስተካከያ በተጠቃሚው ፌስቡክ ገጽ ወደ Chatwoot ለመገናኘት ይምረጡ። ለምርጥ መለያየት የእርስዎን ኢንቦክስ በተለየ ስም ማቅረብ ይችላሉ።"
},
"FINISH": {
"TITLE": "ተሳክቷል!",
@@ -543,7 +551,7 @@
"MESSAGE": "ከአዲሱ ቻናልዎ ጋር ከደንበኞችዎ ጋር እንዲገናኙ አሁን ትችላላችሁ። ደስታ ያለው ድጋፍ",
"BUTTON_TEXT": "ወደ እዚያ ይውሰዱኝ",
"MORE_SETTINGS": "ተጨማሪ ቅንብሮች",
"WEBSITE_SUCCESS": "Դուք հաջողությամբ ստեղծել եք կայքի ալիք։ Նշված կոդը պատճենեք և տեղադրեք Ձեր կայքում։ Հաջորդ անգամ, երբ հաճախորդը օգտագործի ուղիղ զրույցը, հաղորդակցությունը ավտոմատ կհայտնվի Ձեր մուտքային արկղում։",
"WEBSITE_SUCCESS": "የድር ጣቢያ ቻናል መፍጠር በተሳካ ሁኔታ ተጠናቋል። ከታች የተሳየውን ኮድ ቅዳት እና በድር ጣቢያዎ ያስገቡ። ቀጣዩ ጊዜ ደንበኛ በላይቭ ቻት ሲጠቀም ውይይቱ በራሱ በኢንቦክስዎ ይታያል።",
"WHATSAPP_QR_INSTRUCTION": "ለፈጣን ሙከራ የ WhatsApp ጥቅል ላይ ከላይ ያለውን QR ኮድ ይስካን ያድርጉ",
"MESSENGER_QR_INSTRUCTION": "ለፈጣን ሙከራ የ Facebook Messenger ጥቅል ላይ ከላይ ያለውን QR ኮድ ይስካን ያድርጉ",
"TELEGRAM_QR_INSTRUCTION": "ለፈጣን ሙከራ የ Telegram ጥቅል ላይ ከላይ ያለውን QR ኮድ ይስካን ያድርጉ"
@@ -613,9 +621,9 @@
},
"API": {
"SUCCESS_MESSAGE": "ኢንቦክስ በተሳካ ሁኔታ ተሰርዟል",
"ERROR_MESSAGE": "Չհաջողվեց ջնջել մուտքային արկղը։ Խնդրում ենք փորձել ավելի ուշ։",
"ERROR_MESSAGE": "ኢንቦክስ ማጥፋት አልተቻለም። እባክዎ በኋላ ደግመው ይሞክሩ።",
"AVATAR_SUCCESS_MESSAGE": "የኢንቦክስ አቫታር በተሳካ ሁኔታ ተሰርዟል",
"AVATAR_ERROR_MESSAGE": "Չհաջողվեց ջնջել մուտքային արկղի պատկերակը։ Խնդրում ենք փորձել ավելի ուշ։"
"AVATAR_ERROR_MESSAGE": "የኢንቦክስ አቫታር ማጥፋት አልተቻለም። እባክዎ በኋላ ደግመው ይሞክሩ።"
}
},
"TABS": {
@@ -736,24 +744,24 @@
"SENDER_NAME_SECTION": "በኢሜይል ውስጥ የAgent ስም አርግ",
"SENDER_NAME_SECTION_TEXT": "በኢሜይል ውስጥ የAgent ስም እንዲታይ/እንዳይታይ አርግ፣ ካልተከናወነ የንግድ ስም ይታያል",
"ENABLE_CONTINUITY_VIA_EMAIL": "በኢሜል የውይይት ቀጥታነት አንቀሳቅስ",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Եթե կոնտակտի էլ.փոստի հասցեն հասանելի է, զրույցները կշարունակվեն էլ.փոստով։",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "ከኮንታክት ኢሜይል አድራሻ ካለ ውይይቶች በኢሜይል ይቀጥላሉ።",
"LOCK_TO_SINGLE_CONVERSATION": "የውይይት መላኪያ",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "ለአሁን ያሉ እውቂያዎች ውይይት ፍጠራ ያስተካክሉ",
"INBOX_UPDATE_TITLE": "የኢንቦክስ ቅንብሮች",
"INBOX_UPDATE_SUB_TEXT": "የኢንቦክስዎን ቅንብሮች ያዘምኑ",
"AUTO_ASSIGNMENT_SUB_TEXT": "Միացրեք կամ անջատեք նոր հաղորդակցությունները ավտոմատ նշանակումը այս մուտքային արկղին ավելացված գործակալներին։",
"AUTO_ASSIGNMENT_SUB_TEXT": "አዲስ ውይይቶችን ወደ ይህ ኢንቦክስ የተጨመሩ ወኪሎች በራስሰር ማድረግን አቅርቦ ወይም አቋርጦ ያድርጉ።",
"HMAC_VERIFICATION": "የተጠቃሚ መለያ ማረጋገጫ",
"HMAC_DESCRIPTION": "በዚህ ቁልፍ የተለየ ቶክን ማመንጨት ይችላሉ ይህም የተጠቃሚዎችዎን መለያ ለማረጋገጥ ይጠቅማል።.",
"HMAC_LINK_TO_DOCS": "እንደ ተጨማሪ መረጃ እዚህ ማንበብ ይችላሉ።.",
"HMAC_MANDATORY_VERIFICATION": "የተጠቃሚ መለያ ማረጋገጫን አጽድቅ",
"HMAC_MANDATORY_DESCRIPTION": "ከተከፈተ ግምገማዎች ካልተረጋገጡ ጥያቄዎች ይተንቀሳቀሳሉ።.",
"INBOX_IDENTIFIER": "የኢንቦክስ መለያ",
"INBOX_IDENTIFIER_SUB_TEXT": "Օգտագործեք այստեղ նշված `inbox_identifier` տոկենը՝ Ձեր API հաճախորդների վավերացման համար։",
"INBOX_IDENTIFIER_SUB_TEXT": "የ API ደንበኞችዎን ማረጋገጫ ለማድረግ እዚህ የተሳየውን `inbox_identifier` ቶክን ይጠቀሙ።",
"FORWARD_EMAIL_TITLE": "ወደ ኢሜይል አስቀምጥ",
"FORWARD_EMAIL_SUB_TEXT": "Սկսեք Ձեր էլ.փոստերը ուղարկել հետևյալ էլ.փոստի հասցեին։",
"FORWARD_EMAIL_SUB_TEXT": "ኢሜይሎችዎን ወደ ቀጣዩ ኢሜይል አድራሻ መላክ ይጀምሩ።",
"FORWARD_EMAIL_NOT_CONFIGURED": "ኢሜይሎችን ወደ ኢንቦክስዎ መቀላቀል በዚህ መገናኛ አሁን አልተፈቀደም። ይህን ባህሪ ለመጠቀም ከአስተዳደሩ መፍቀድ አለቦት። እባክዎ ለመቀጠል ከእነርሱ ጋር ያገናኙ።.",
"ALLOW_MESSAGES_AFTER_RESOLVED": "ከውይይት መፍታት በኋላ መልእክቶችን እንዲፈቀድ",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Թույլ տվեք վերջնական օգտատերերին ուղարկել հաղորդագրություններ նույնիսկ զրույցի լուծումից հետո։",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "ውይይቱ ከተፈታ በኋላም እንደገና መልእክቶችን ለመላክ ለመጠቀሚያ ተጠቃሚዎች ፈቃድ ይስጡ።",
"WHATSAPP_SECTION_SUBHEADER": "ይህ የAPI ቁልፍ ለWhatsApp API ጋር ለመያዝ ይጠቅማል።.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "ለWhatsApp API ጋር ለመያዝ አዲሱን የAPI ቁልፍ ያስገቡ።.",
"WHATSAPP_SECTION_TITLE": "API ቁልፍ",
@@ -850,7 +858,7 @@
"MESSAGE_ERROR": "ስህተት አጋጥሟል፣ እባክዎ እንደገና ይሞክሩ"
},
"PRE_CHAT_FORM": {
"DESCRIPTION": "Նախնական զրույցի ձևերը թույլ են տալիս հավաքել օգտատիրոջ տեղեկությունները նախքան զրույցի սկսելը։",
"DESCRIPTION": "የቀድሞ ቻት ቅጥያዎች ተጠቃሚ መረጃ ከመደምደሚያ በፊት ለመሰብሰብ ይፈቅዳሉ።",
"SET_FIELDS": "የቀደም የውይይት ቅጽ መስኮች",
"SET_FIELDS_HEADER": {
"FIELDS": "መስኮች",
@@ -960,7 +968,7 @@
"HOURS": "ሰዓታት",
"ENABLE": "ለዚህ ቀን እንደሚገኙ አንቀሳቅስ",
"UNAVAILABLE": "አይገኝም",
"VALIDATION_ERROR": "Սկիզբի ժամանակը պետք է լինի փակման ժամանակից առաջ։",
"VALIDATION_ERROR": "የመጀመሪያ ሰዓት ከመዝጊያ ሰዓት በፊት መሆን አለበት።",
"CHOOSE": "ይምረጡ"
},
"ALL_DAY": "ቀኑን ሙሉ"

View File

@@ -738,6 +738,17 @@
"DOCUMENTS": {
"HEADER": "ሰነዶች",
"ADD_NEW": "አዲስ ሰነድ ፍጠር",
"SELECTED": "{count} ተመረጡ",
"SELECT_ALL": "ሁሉንም ይምረጡ ({count})",
"UNSELECT_ALL": "ሁሉንም አልምረጥም ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
"CONFIRM": "አዎን፣ ሁሉንም አጥፋ",
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"RELATED_RESPONSES": {
"TITLE": "ተዛማጅ የFAQ ጥያቄዎች",
"DESCRIPTION": "እነዚህ የFAQ ጥያቄዎች ቀጥተኛ ከሰነዱ ተፈጥረዋል።"
@@ -795,6 +806,7 @@
"CUSTOM_TOOLS": {
"HEADER": "መሣሪያዎች",
"ADD_NEW": "አዲስ መሣሪያ ይፍጠሩ",
"SOFT_LIMIT_WARNING": "Having more than 10 tools may reduce the assistant's reliability in selecting the right tool. Consider removing unused tools for better results.",
"EMPTY_STATE": {
"TITLE": "ምንም የተለየ መሣሪያዎች አልተገኙም",
"SUBTITLE": "እርስዎን ከውጭ ኤፒአይዎችና አገልግሎቶች ጋር ለማገናኘት የተለየ መሣሪያዎችን ይፍጠሩ፣ እንዲሁም እርስዎን በአካል ውስጥ መረጃ ለማግኘትና ለማከናወን ይፈቅዱ።",
@@ -825,11 +837,30 @@
"SUCCESS_MESSAGE": "ብልጽግና ተጠቃሚ መሣሪያ ተሰርዟል",
"ERROR_MESSAGE": "ብልጽግና መሣሪያውን ማስወገድ አልተሳካም"
},
"PAYWALL": {
"TITLE": "Upgrade to use tools with Captain",
"AVAILABLE_ON": "Captain Tools are only available in Business and Enterprise plans. Please upgrade to Business plan to use the feature.",
"UPGRADE_PROMPT": "",
"UPGRADE_NOW": "Open billing",
"CANCEL_ANYTIME": ""
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain Tools are only available in the paid plans.",
"UPGRADE_PROMPT": "Please upgrade to a paid plan to use this feature.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"TEST": {
"BUTTON": "Test connection",
"SUCCESS": "Endpoint returned HTTP {status}",
"ERROR": "Connection failed",
"DISABLED_HINT": "Testing is only available for endpoints without templates or request bodies."
},
"FORM": {
"TITLE": {
"LABEL": "የመሣሪያ ስም",
"PLACEHOLDER": "የትዕዛዝ ፍለጋ",
"ERROR": "የመሣሪያ ስም አስፈላጊ ነው"
"ERROR": "የመሣሪያ ስም አስፈላጊ ነው",
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
},
"DESCRIPTION": {
"LABEL": "መግለጫ",

View File

@@ -93,6 +93,7 @@
"ASSIGN_AGENT": "Assign an Agent",
"ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript",
"MUTE_CONVERSATION": "Mute Conversation",

View File

@@ -158,54 +158,54 @@
"TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"NAME": "የመፍትሄ ጊዜ",
"DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"DESC": "( Total )"
"NAME": "የተፈታ ብዛት",
"DESC": "( ጠቅላላ )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Last 7 days"
"name": "ያለፉት 7 ቀናት"
},
{
"id": 1,
"name": "Last 30 days"
"name": "ያለፉት 30 ቀናት"
},
{
"id": 2,
"name": "Last 3 months"
"name": "ያለፉት 3 ወራት"
},
{
"id": 3,
"name": "Last 6 months"
"name": "ያለፉት 6 ወራት"
},
{
"id": 4,
"name": "Last year"
"name": "ያለፈው ዓመት"
},
{
"id": 5,
"name": "Custom date range"
"name": "በተለይ የተመረጠ ቀን ክልል"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
"CONFIRM": "ተግባሩን ተፈጽም",
"PLACEHOLDER": "የቀን ክልል ይምረጡ"
}
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"HEADER": "የመለያ አጠቃላይ እይታ",
"DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
"FILTER_DROPDOWN_LABEL": "Select Label",
"LOADING_CHART": "የገበታ ውሂብ በመጫን ላይ...",
"NO_ENOUGH_DATA": "የበቂ ውሂብ አልተሰበሰበም፣ እባክዎ በኋላ ይሞክሩ።",
"DOWNLOAD_LABEL_REPORTS": "የመለያ ሪፖርቶችን ይውሰዱ",
"FILTER_DROPDOWN_LABEL": "መለያ ይምረጡ",
"FILTERS": {
"INPUT_PLACEHOLDER": {
"LABELS": "Search labels"
@@ -213,71 +213,71 @@
},
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
"DESC": "( Total )"
"NAME": "ውይይቶች",
"DESC": "( ድምር )"
},
"INCOMING_MESSAGES": {
"NAME": "Incoming Messages",
"DESC": "( Total )"
"NAME": "የሚገቡ መልእክቶች",
"DESC": "( ድምር )"
},
"OUTGOING_MESSAGES": {
"NAME": "Outgoing Messages",
"DESC": "( Total )"
"NAME": "የወጪ መልእክቶች",
"DESC": "( ድምር )"
},
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "( Avg )",
"DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is {metricValue} (based on {conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"NAME": "የመፍትሄ ጊዜ",
"DESC": "( አማካይ )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is {metricValue} (based on {conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
"DESC": "( Total )"
"NAME": "የመፍትሄ ብዛት",
"DESC": "( ድምር )"
}
},
"DATE_RANGE": [
{
"id": 0,
"name": "Last 7 days"
"name": "ያለፉ 7 ቀናት"
},
{
"id": 1,
"name": "Last 30 days"
"name": "ያለፉ 30 ቀናት"
},
{
"id": 2,
"name": "Last 3 months"
"name": "ያለፉት 3 ወራት"
},
{
"id": 3,
"name": "Last 6 months"
"name": "ያለፉት 6 ወራት"
},
{
"id": 4,
"name": "Last year"
"name": "ያለፈው ዓመት"
},
{
"id": 5,
"name": "Custom date range"
"name": "በተፈጥሮ የተመረጠ የቀን ክልል"
}
],
"CUSTOM_DATE_RANGE": {
"CONFIRM": "Apply",
"PLACEHOLDER": "Select date range"
"CONFIRM": "ተግባር አድርግ",
"PLACEHOLDER": "የቀን ክልል ይምረጡ"
}
},
"INBOX_REPORTS": {
"HEADER": "Inbox Overview",
"HEADER": "የኢንቦክስ አጠቃላይ እይታ",
"DESCRIPTION": "Quickly view your inbox performance with key metrics like conversations, response times, resolution times, and resolved cases—all in one place. Click an inbox name for more details.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"LOADING_CHART": "የገቢ ግምገማ መረጃ በመጫን ላይ...",
"NO_ENOUGH_DATA": "ሪፖርት ለማዘጋጀት በቂ መረጃ አልተደረሰም። እባክዎ በኋላ ይሞክሩ።",
"DOWNLOAD_INBOX_REPORTS": "የኢንቦክስ ሪፖርቶችን ይውሰዱ",
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",

View File

@@ -68,7 +68,8 @@
"API_SUCCESS": "ፊርማው በተሳካ ሁኔታ ተቀምጧል",
"IMAGE_UPLOAD_ERROR": "ምስሉን ማስገባት አልተቻለም! እባክዎ እንደገና ይሞክሩ",
"IMAGE_UPLOAD_SUCCESS": "ምስሉ በተሳካ ሁኔታ ታክሏል። እባክዎ ማስቀመጫ ለማስቀመጥ ላይ ይጫኑ",
"IMAGE_UPLOAD_SIZE_ERROR": "የምስል መጠን ከ{size}MB በታች መሆን አለበት"
"IMAGE_UPLOAD_SIZE_ERROR": "የምስል መጠን ከ{size}MB በታች መሆን አለበት",
"INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature."
},
"MESSAGE_SIGNATURE": {
"LABEL": "የመልእክት ፊርማ",

View File

@@ -45,6 +45,13 @@
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"SUBMIT": "Create account",
"HAVE_AN_ACCOUNT": "Already have an account?"
"HAVE_AN_ACCOUNT": "Already have an account?",
"VERIFY_EMAIL": {
"TITLE": "Check your inbox",
"DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.",
"RESEND": "የማረጋገጫ ኢሜል እንደገና ላክ",
"RESEND_SUCCESS": "Verification email sent. Please check your inbox.",
"RESEND_ERROR": "Could not send verification email. Please try again."
}
}
}

View File

@@ -63,6 +63,16 @@
"ERROR_MESSAGE": "تعذر تحديث الروبوت. يرجى المحاولة مرة أخرى."
}
},
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "Done",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": {
"TITLE": "رمز المصادقة",
"DESCRIPTION": "Copy the access token and save it securely",

View File

@@ -140,6 +140,8 @@
"ACTIONS": {
"ASSIGN_AGENT": "Assign to Agent",
"ASSIGN_TEAM": "Assign a Team",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label",
"SEND_EMAIL_TO_TEAM": "Send an Email to Team",
@@ -169,6 +171,7 @@
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "إضافة ملاحظة خاصة",
"MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "البريد الإلكتروني",
"INBOX": "صندوق الوارد",

View File

@@ -52,5 +52,17 @@
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
},
"SLASH_COMMANDS": {
"HEADING_1": "Heading 1",
"HEADING_2": "Heading 2",
"HEADING_3": "Heading 3",
"BOLD": "Bold",
"ITALIC": "Italic",
"STRIKETHROUGH": "Strikethrough",
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
}
}

View File

@@ -20,6 +20,7 @@
"CALL": "Call",
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},

View File

@@ -316,6 +316,18 @@
"SUCCESS_MESSAGE": "تم إزالة اللغة من البوابة بنجاح",
"ERROR_MESSAGE": "غير قادر على إزالة اللغة من البوابة. حاول مرة أخرى."
}
},
"DRAFT_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale moved to draft successfully",
"ERROR_MESSAGE": "Unable to move locale to draft. Try again."
}
},
"PUBLISH_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale published successfully",
"ERROR_MESSAGE": "Unable to publish locale. Try again."
}
}
},
"TABLE": {
@@ -644,8 +656,11 @@
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
"DEFAULT": "افتراضي",
"DRAFT": "مسودة",
"DROPDOWN_MENU": {
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"DELETE": "حذف"
}
},
@@ -655,6 +670,13 @@
"COMBOBOX": {
"PLACEHOLDER": "حدد اللغة..."
},
"STATUS": {
"LABEL": "الحالة",
"OPTIONS": {
"LIVE": "نُشرت",
"DRAFT": "مسودة"
}
},
"API": {
"SUCCESS_MESSAGE": "تمت إضافة اللغة بنجاح",
"ERROR_MESSAGE": "غير قادر على إضافة اللغة . حاول مرة أخرى."

View File

@@ -86,6 +86,14 @@
"PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "الرجاء إدخال عنوان URL صالح"
},
"CHANNEL_WEBHOOK_SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"CHANNEL_DOMAIN": {
"LABEL": "نطاق الموقع",
"PLACEHOLDER": "أدخل نطاق موقعك الإلكتروني (مثال: acme.com)"
@@ -715,7 +723,7 @@
},
"ALLOW_MOBILE_WEBVIEW": {
"LABEL": "Enable widget in mobile apps",
"SUBTITLE": "Check this if you embed the widget in iOS or Android apps. Mobile apps don't send domain information, so they would be blocked by domain restrictions unless this is enabled."
"SUBTITLE": "حدد هذا الخيار إذا كنت تقوم بتضمين الأداة في تطبيقات iOS أو Android. لا ترسل تطبيقات الجوال معلومات النطاق، لذا سيتم حظرها بسبب قيود النطاق ما لم يتم تفعيل هذا الخيار."
},
"IDENTITY_VALIDATION": {
"TITLE": "Identity Validation",

View File

@@ -126,7 +126,7 @@
},
"HELP_TEXT": {
"TITLE": "استخدام تكامل Slack",
"BODY": "With this integration, all of your incoming conversations will be synced to the ***{selectedChannelName}*** channel in your Slack workspace. You can manage all your customer conversations right within the channel and never miss a message.\n\nHere are the main features of the integration:\n\n**Respond to conversations from within Slack:** To respond to a conversation in the ***{selectedChannelName}*** Slack channel, simply type out your message and send it as a thread. This will create a response back to the customer through Chatwoot. It's that simple!\n\n **Create private notes:** If you want to create private notes instead of replies, start your message with ***`note:`***. This ensures that your message is kept private and won't be visible to the customer.\n\n**Associate an agent profile:** If the person who replied on Slack has an agent profile in Chatwoot under the same email, the replies will be associated with that agent profile automatically. This means you can easily track who said what and when. On the other hand, when the replier doesn't have an associated agent profile, the replies will appear from the bot profile to the customer.",
"BODY": "باستخدام هذا التكامل، ستتم مزامنة جميع محادثاتك الواردة مع قناة ***{selectedChannelName}*** في مساحة عمل Slack الخاصة بك. يمكنك إدارة جميع محادثات عملائك مباشرة من داخل القناة ولن تفوّت أي رسالة.\n\nفيما يلي الميزات الرئيسية لهذا التكامل:\n\n**الرد على المحادثات من داخل Slack:** للرد على محادثة في قناة Slack ***{selectedChannelName}***، ما عليك سوى كتابة رسالتك وإرسالها كسلسلة رسائل. سيؤدي ذلك إلى إنشاء رد للعميل عبر Chatwoot. الأمر بهذه البساطة!\n\n **إنشاء ملاحظات خاصة:** إذا كنت تريد إنشاء ملاحظات خاصة بدلاً من الردود، فابدأ رسالتك بـ ***`note:`***. يضمن ذلك بقاء رسالتك خاصة وعدم ظهورها للعميل.\n\n**ربط ملف وكيل:** إذا كان الشخص الذي ردّ في Slack لديه ملف وكيل في Chatwoot تحت البريد الإلكتروني نفسه، فسيتم ربط الردود تلقائيًا بذلك الملف. وهذا يعني أنه يمكنك بسهولة تتبع من قال ماذا ومتى. من ناحية أخرى، إذا لم يكن لدى الشخص الذي ردّ ملف وكيل مرتبط، فستظهر الردود للعميل على أنها صادرة من ملف البوت.",
"SELECTED": "selected"
},
"SELECT_CHANNEL": {
@@ -390,72 +390,72 @@
},
"CAPTAIN": {
"NAME": "قائد",
"HEADER_KNOW_MORE": "Know more",
"HEADER_KNOW_MORE": "اعرف المزيد",
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started"
"ASSISTANTS": "المساعدون",
"SWITCH_ASSISTANT": "التبديل بين المساعدين",
"NEW_ASSISTANT": "إنشاء مساعد",
"EMPTY_LIST": "لم يتم العثور على مساعدين، يرجى إنشاء واحد للبدء"
},
"COPILOT": {
"TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"PANEL_TITLE": "ابدأ مع Copilot",
"KICK_OFF_MESSAGE": "هل تحتاج إلى ملخص سريع، ترغب في مراجعة المحادثات السابقة، أو صياغة رد أفضل؟ Copilot هنا لتسريع الأمور.",
"SEND_MESSAGE": "إرسال الرسالة...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
"EMPTY_MESSAGE": "حدث خطأ أثناء توليد الاستجابة. يرجى المحاولة مرة أخرى.",
"LOADER": "يقوم Captain بالتفكير",
"YOU": "أنت",
"USE": "Use this",
"RESET": "Reset",
"SHOW_STEPS": "Show steps",
"SELECT_ASSISTANT": "Select Assistant",
"USE": "استخدام هذا",
"RESET": "إعادة تعيين",
"SHOW_STEPS": "عرض الخطوات",
"SELECT_ASSISTANT": "اختر المساعد",
"PROMPTS": {
"SUMMARIZE": {
"LABEL": "Summarize this conversation",
"CONTENT": "Summarize the key points discussed between the customer and the support agent, including the customer's concerns, questions, and the solutions or responses provided by the support agent"
"LABEL": "لخص هذه المحادثة",
"CONTENT": "لخص النقاط الرئيسية التي نوقشت بين العميل ووكيل الدعم، بما في ذلك مخاوف العميل، أسئلته، والحلول أو الردود المقدمة من الوكيل."
},
"SUGGEST": {
"LABEL": "Suggest an answer",
"CONTENT": "Analyze the customer's inquiry, and draft a response that effectively addresses their concerns or questions. Ensure the reply is clear, concise, and provides helpful information."
"LABEL": "اقترح إجابة",
"CONTENT": "حلل استفسار العميل وقم بصياغة رد يعالج مخاوفه أو أسئلته بفعالية. تأكد من أن الرد واضح، موجز، ويوفر معلومات مفيدة."
},
"RATE": {
"LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
"LABEL": "قم بتقييم هذه المحادثة",
"CONTENT": "راجع المحادثة لترى مدى تلبيتها لاحتياجات العميل. شارك تقييمًا من 5 بناءً على النغمة، الوضوح، والفعالية."
},
"HIGH_PRIORITY": {
"LABEL": "High priority conversations",
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
"LABEL": "المحادثات ذات الأولوية العالية",
"CONTENT": "اعطني ملخصًا لجميع المحادثات المفتوحة ذات الأولوية العالية. تضمّن معرف المحادثة، اسم العميل (إن وُجد)، محتوى آخر رسالة، والوكيل المعين. قم بالتجميع حسب الحالة إذا كان ذلك مناسبًا."
},
"LIST_CONTACTS": {
"LABEL": "List contacts",
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
"LABEL": "قائمة جهات الاتصال",
"CONTENT": "اعرض لي قائمة بأفضل 10 جهات اتصال. تضمّن الاسم، البريد الإلكتروني أو رقم الهاتف (إن وُجد)، آخر وقت مشاهدة، العلامات (إن وجدت)."
}
}
},
"PLAYGROUND": {
"USER": "أنت",
"ASSISTANT": "Assistant",
"ASSISTANT": "مساعد",
"MESSAGE_PLACEHOLDER": "أكتب رسالتك...",
"HEADER": "Playground",
"DESCRIPTION": "Use this playground to send messages to your assistant and check if it responds accurately, quickly, and in the tone you expect.",
"CREDIT_NOTE": "Messages sent here will count toward your Captain credits."
"HEADER": "ساحة اللعب",
"DESCRIPTION": "استخدم هذه الساحة لإرسال رسائل إلى مساعدك والتحقق مما إذا كان يرد بدقة وسرعة وبالنغمة التي تتوقعها.",
"CREDIT_NOTE": "الرسائل المرسلة هنا ستُحتسب ضمن رصيد Captain الخاص بك."
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
"AVAILABLE_ON": "Captain is not available on the free plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"TITLE": "قم بالترقية لاستخدام Captain AI",
"AVAILABLE_ON": "Captain غير متاح على الخطة المجانية.",
"UPGRADE_PROMPT": "قم بترقية خطتك للحصول على الوصول إلى مساعدينا، وcopilot، والمزيد.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "ولا يتوفر الكابتن AI إلا في خطط المؤسسة.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"UPGRADE_PROMPT": "قم بترقية خطتك للحصول على الوصول إلى مساعدينا، وcopilot، والمزيد.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"BANNER": {
"RESPONSES": "You've used over 80% of your response limit. To continue using Captain AI, please upgrade.",
"DOCUMENTS": "Document limit reached. Upgrade to continue using Captain AI."
"RESPONSES": "لقد استخدمت أكثر من 80٪ من حد الاستجابات الخاص بك. للاستمرار في استخدام Captain AI، يرجى الترقية.",
"DOCUMENTS": "تم الوصول إلى حد المستندات. قم بالترقية للاستمرار في استخدام Captain AI."
},
"FORM": {
"CANCEL": "إلغاء",
@@ -738,6 +738,17 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "حذف",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -795,6 +806,7 @@
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"SOFT_LIMIT_WARNING": "Having more than 10 tools may reduce the assistant's reliability in selecting the right tool. Consider removing unused tools for better results.",
"EMPTY_STATE": {
"TITLE": "No custom tools available",
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
@@ -825,11 +837,30 @@
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"PAYWALL": {
"TITLE": "Upgrade to use tools with Captain",
"AVAILABLE_ON": "Captain Tools are only available in Business and Enterprise plans. Please upgrade to Business plan to use the feature.",
"UPGRADE_PROMPT": "",
"UPGRADE_NOW": "فتح الفواتير",
"CANCEL_ANYTIME": ""
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain Tools are only available in the paid plans.",
"UPGRADE_PROMPT": "Please upgrade to a paid plan to use this feature.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"TEST": {
"BUTTON": "Test connection",
"SUCCESS": "Endpoint returned HTTP {status}",
"ERROR": "Connection failed",
"DISABLED_HINT": "Testing is only available for endpoints without templates or request bodies."
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
"ERROR": "Tool name is required",
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
},
"DESCRIPTION": {
"LABEL": "الوصف",

View File

@@ -93,6 +93,7 @@
"ASSIGN_AGENT": "Assign an Agent",
"ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"SEND_EMAIL_TRANSCRIPT": "Send an Email Transcript",
"MUTE_CONVERSATION": "كتم المحادثة",

View File

@@ -68,7 +68,8 @@
"API_SUCCESS": "تم حفظ التوقيع بنجاح",
"IMAGE_UPLOAD_ERROR": "تعذر رفع الصورة! حاول مرة أخرى",
"IMAGE_UPLOAD_SUCCESS": "تم إضافة الصورة بنجاح. الرجاء النقر على الحفظ لحفظ التوقيع",
"IMAGE_UPLOAD_SIZE_ERROR": "حجم الصورة يجب أن يكون أقل من {size}MB"
"IMAGE_UPLOAD_SIZE_ERROR": "حجم الصورة يجب أن يكون أقل من {size}MB",
"INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature."
},
"MESSAGE_SIGNATURE": {
"LABEL": "توقيع الرسالة",

View File

@@ -45,6 +45,13 @@
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"SUBMIT": "إرسال",
"HAVE_AN_ACCOUNT": "هل لديك حساب مسبق؟"
"HAVE_AN_ACCOUNT": "هل لديك حساب مسبق؟",
"VERIFY_EMAIL": {
"TITLE": "Check your inbox",
"DESCRIPTION": "We sent a verification link to {email}. Click the link to verify your email and get started.",
"RESEND": "إعادة إرسال رسالة التحقق",
"RESEND_SUCCESS": "Verification email sent. Please check your inbox.",
"RESEND_ERROR": "Could not send verification email. Please try again."
}
}
}

View File

@@ -63,6 +63,16 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"SECRET": {
"LABEL": "Webhook Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Use the secret below to verify webhook signatures. Please copy it now, you can also find it later in the bot settings.",
"DONE": "Hazır",
"RESET_SUCCESS": "Webhook secret regenerated successfully",
"RESET_ERROR": "Unable to regenerate webhook secret. Please try again"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",

View File

@@ -140,6 +140,8 @@
"ACTIONS": {
"ASSIGN_AGENT": "Assign to Agent",
"ASSIGN_TEAM": "Assign a Team",
"REMOVE_ASSIGNED_AGENT": "Remove Assigned Agent",
"REMOVE_ASSIGNED_TEAM": "Remove Assigned Team",
"ADD_LABEL": "Add a Label",
"REMOVE_LABEL": "Remove a Label",
"SEND_EMAIL_TO_TEAM": "Send an Email to Team",
@@ -169,6 +171,7 @@
},
"ATTRIBUTES": {
"MESSAGE_TYPE": "Message Type",
"PRIVATE_NOTE": "Şəxsi Qeyd",
"MESSAGE_CONTAINS": "Message Contains",
"EMAIL": "Email",
"INBOX": "Inbox",

View File

@@ -52,5 +52,17 @@
},
"CHANNEL_SELECTOR": {
"COMING_SOON": "Coming Soon!"
},
"SLASH_COMMANDS": {
"HEADING_1": "Heading 1",
"HEADING_2": "Heading 2",
"HEADING_3": "Heading 3",
"BOLD": "Bold",
"ITALIC": "Italic",
"STRIKETHROUGH": "Strikethrough",
"CODE": "Code",
"BULLET_LIST": "Bullet List",
"ORDERED_LIST": "Ordered List",
"TABLE": "Table"
}
}

View File

@@ -20,6 +20,7 @@
"CALL": "Zəng et",
"CALL_INITIATED": "Calling the contact…",
"CALL_FAILED": "Unable to start the call. Please try again.",
"CLICK_TO_EDIT": "Click to edit",
"VOICE_INBOX_PICKER": {
"TITLE": "Choose a voice inbox"
},

View File

@@ -316,6 +316,18 @@
"SUCCESS_MESSAGE": "Dil portaldan uğurla silindi",
"ERROR_MESSAGE": "Dili portaldan silmək mümkün olmadı. Yenidən cəhd edin."
}
},
"DRAFT_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale moved to draft successfully",
"ERROR_MESSAGE": "Unable to move locale to draft. Try again."
}
},
"PUBLISH_LOCALE": {
"API": {
"SUCCESS_MESSAGE": "Locale published successfully",
"ERROR_MESSAGE": "Unable to publish locale. Try again."
}
}
},
"TABLE": {
@@ -644,8 +656,11 @@
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
"DEFAULT": "Default",
"DRAFT": "Qaralama",
"DROPDOWN_MENU": {
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"DELETE": "Delete"
}
},
@@ -655,6 +670,13 @@
"COMBOBOX": {
"PLACEHOLDER": "Select locale..."
},
"STATUS": {
"LABEL": "Status",
"OPTIONS": {
"LIVE": "Yayımlanıb",
"DRAFT": "Qaralama"
}
},
"API": {
"SUCCESS_MESSAGE": "Locale added successfully",
"ERROR_MESSAGE": "Unable to add locale. Try again."
@@ -676,7 +698,7 @@
"EDIT_ARTICLE": {
"MORE_PROPERTIES": "More properties",
"UNCATEGORIZED": "Uncategorized",
"EDITOR_PLACEHOLDER": "Write something..."
"EDITOR_PLACEHOLDER": "Write your content here. Type '/' for formatting options."
},
"ARTICLE_PROPERTIES": {
"ARTICLE_PROPERTIES": "Article properties",

Some files were not shown because too many files have changed in this diff Show More