Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Shivam Mishra
2025-10-13 12:30:50 +05:30
committed by GitHub
905 changed files with 30845 additions and 1827 deletions

View File

@@ -103,7 +103,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.5.2'
gem 'slack-ruby-client', '~> 2.7.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc'

View File

@@ -292,7 +292,7 @@ GEM
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
faraday-mashify (1.0.0)
faraday (~> 2.0)
hashie
faraday-multipart (1.0.4)
@@ -644,7 +644,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.0)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -876,8 +876,8 @@ GEM
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
slack-ruby-client (2.5.2)
faraday (>= 2.0)
slack-ruby-client (2.7.0)
faraday (>= 2.0.1)
faraday-mashify
faraday-multipart
gli
@@ -935,7 +935,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.0.3)
uri (1.0.4)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1103,7 +1103,7 @@ DEPENDENCIES
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
slack-ruby-client (~> 2.5.2)
slack-ruby-client (~> 2.7.0)
spring
spring-watcher-listen
squasher

View File

@@ -0,0 +1,54 @@
class Email::BaseBuilder
pattr_initialize [:inbox!]
private
def channel
@channel ||= inbox.channel
end
def account
@account ||= inbox.account
end
def conversation
@conversation ||= message.conversation
end
def custom_sender_name
message&.sender&.available_name || I18n.t('conversations.reply.email.header.notifications')
end
def sender_name(sender_email)
# Friendly: <agent_name> from <business_name>
# Professional: <business_name>
if inbox.friendly?
I18n.t(
'conversations.reply.email.header.friendly_name',
sender_name: custom_sender_name,
business_name: business_name,
from_email: sender_email
)
else
I18n.t(
'conversations.reply.email.header.professional_name',
business_name: business_name,
from_email: sender_email
)
end
end
def business_name
inbox.business_name || inbox.sanitized_name
end
def account_support_email
# Parse the email to ensure it's in the correct format, the user
# 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

@@ -0,0 +1,51 @@
class Email::FromBuilder < Email::BaseBuilder
pattr_initialize [:inbox!, :message!]
def build
return sender_name(account_support_email) unless inbox.email?
from_email = case email_channel_type
when :standard_imap_smtp,
:google_oauth,
:microsoft_oauth,
:forwarding_own_smtp
channel.email
when :imap_chatwoot_smtp,
:forwarding_chatwoot_smtp
channel.verified_for_sending ? channel.email : account_support_email
else
account_support_email
end
sender_name(from_email)
end
private
def email_channel_type
return :google_oauth if channel.google?
return :microsoft_oauth if channel.microsoft?
return :standard_imap_smtp if imap_and_smtp_enabled?
return :imap_chatwoot_smtp if imap_enabled_without_smtp?
return :forwarding_own_smtp if forwarding_with_own_smtp?
return :forwarding_chatwoot_smtp if forwarding_without_smtp?
:unknown
end
def imap_and_smtp_enabled?
channel.imap_enabled && channel.smtp_enabled
end
def imap_enabled_without_smtp?
channel.imap_enabled && !channel.smtp_enabled
end
def forwarding_with_own_smtp?
!channel.imap_enabled && channel.smtp_enabled
end
def forwarding_without_smtp?
!channel.imap_enabled && !channel.smtp_enabled
end
end

View File

@@ -0,0 +1,21 @@
class Email::ReplyToBuilder < Email::BaseBuilder
pattr_initialize [:inbox!, :message!]
def build
reply_to = if inbox.email?
channel.email
elsif inbound_email_enabled?
"reply+#{conversation.uuid}@#{account.inbound_email_domain}"
else
account_support_email
end
sender_name(reply_to)
end
private
def inbound_email_enabled?
account.feature_enabled?('inbound_emails') && account.inbound_email_domain.present?
end
end

View File

@@ -7,6 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
@@ -20,6 +21,9 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content if @account.feature_enabled?(:quoted_email_reply)
@message.save!
@message
end
@@ -92,6 +96,14 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails
end
def process_email_content
return unless should_process_email_content?
@message.content_attributes ||= {}
email_attributes = build_email_attributes
@message.content_attributes[:email] = email_attributes
end
def process_email_string(email_string)
return [] if email_string.blank?
@@ -153,4 +165,52 @@ class Messages::MessageBuilder
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
def email_inbox?
@conversation.inbox&.inbox_type == 'Email'
end
def should_process_email_content?
email_inbox? && !@private && @message.content.present?
end
def build_email_attributes
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
email_attributes[:html_content] = build_html_content(normalized_content)
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes
end
def build_html_content(normalized_content)
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
rendered_html = render_email_html(normalized_content)
html_content[:full] = rendered_html
html_content[:reply] = rendered_html
html_content
end
def build_text_content(normalized_content)
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
text_content[:full] = normalized_content
text_content[:reply] = normalized_content
text_content
end
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
end

View File

@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts)
@contacts_count = @contacts.total_count
end
def search
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def show; end
@@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contacts(contacts)
contacts_with_avatar = filtrate(contacts)
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
# Build includes hash to avoid separate query when contact_inboxes are needed
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_avatar
filtrate(contacts)
.includes(includes_hash)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def build_contact_inbox

View File

@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])

View File

@@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end
def sign_in_user_on_mobile
@resource.skip_confirmation! if confirmable_enabled?
# once the resource is found and verified
# we can just send them to the login page again with the SSO params
# that will log them in
encoded_email = ERB::Util.url_encode(@resource.email)
params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
end
def sign_up_user
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?

View File

@@ -12,9 +12,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
return handle_mfa_verification if mfa_verification_request?
return handle_sso_authentication if sso_authentication_request?
super do |resource|
return handle_mfa_required(resource) if resource&.mfa_enabled?
end
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
@@ -23,6 +25,17 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private
def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present?
normalized_email = params[:email].strip.downcase
user = User.from_email(normalized_email)
return nil unless user&.valid_password?(params[:password])
return nil unless user.active_for_authentication?
user
end
def mfa_verification_request?
params[:mfa_token].present?
end
@@ -59,10 +72,10 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(resource)
def handle_mfa_required(user)
render json: {
mfa_required: true,
mfa_token: Mfa::TokenService.new(user: resource).generate_token
mfa_token: Mfa::TokenService.new(user: user).generate_token
}, status: :partial_content
end

View File

@@ -3,7 +3,7 @@ class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::Inbox
before_action :set_conversation, only: [:toggle_typing, :update_last_seen, :show, :toggle_status]
def index
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
@conversations = @contact_inbox.hmac_verified? ? @contact_inbox.contact.conversations : @contact_inbox.conversations
end
def show; end

View File

@@ -13,11 +13,11 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
redirect_to new_super_admin_user_path, notice: notice
end
end
#
# def update
# super
# send_foo_updated_email(requested_resource)
# end
def update
requested_resource.skip_reconfirmation! if resource_params[:confirmed_at].present?
super
end
# Override this method to specify custom lookup behavior.
# This will be used to set the resource for the `show`, `edit`, and `update`

View File

@@ -59,11 +59,11 @@ class UserDashboard < Administrate::BaseDashboard
SHOW_PAGE_ATTRIBUTES = %i[
id
avatar_url
unconfirmed_email
name
type
display_name
email
unconfirmed_email
created_at
updated_at
confirmed_at

View File

@@ -6,19 +6,54 @@ class EmailChannelFinder
end
def perform
channel = nil
recipient_mails.each do |email|
normalized_email = normalize_email_with_plus_addressing(email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
break if channel.present?
end
channel
channel_from_primary_recipients || channel_from_bcc_recipients
end
def recipient_mails
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
recipient_addresses.flatten.compact
private
def channel_from_primary_recipients
primary_recipient_emails.each do |email|
channel = channel_from_email(email)
return channel if channel.present?
end
nil
end
def channel_from_bcc_recipients
bcc_recipient_emails.each do |email|
channel = channel_from_email(email)
# Skip if BCC processing is disabled for this account
next if channel && !allow_bcc_processing?(channel.account_id)
return channel if channel.present?
end
nil
end
def primary_recipient_emails
(@email_object.to.to_a + @email_object.cc.to_a + [@email_object['X-Original-To'].try(:value)]).flatten.compact
end
def bcc_recipient_emails
@email_object.bcc.to_a.flatten.compact
end
def channel_from_email(email)
normalized_email = normalize_email_with_plus_addressing(email)
Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
end
def bcc_processing_skipped_accounts
config_value = GlobalConfigService.load('SKIP_INCOMING_BCC_PROCESSING', '')
return [] if config_value.blank?
config_value.split(',').map(&:to_i)
end
def allow_bcc_processing?(account_id)
bcc_processing_skipped_accounts.exclude?(account_id)
end
end

View File

@@ -0,0 +1,36 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainCustomTools extends ApiClient {
constructor() {
super('captain/custom_tools', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: { page, searchKey },
});
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
create(data = {}) {
return axios.post(this.url, {
custom_tool: data,
});
}
update(id, data = {}) {
return axios.put(`${this.url}/${id}`, {
custom_tool: data,
});
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new CaptainCustomTools();

View File

@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class InboxHealthAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
}
export default new InboxHealthAPI();

View File

@@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => {
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
};
const handleDropdownUpdate = (type, value) => {

View File

@@ -10,6 +10,10 @@ const props = defineProps({
type: String,
required: true,
},
translationKey: {
type: String,
required: true,
},
entity: {
type: Object,
required: true,
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const deleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
return props.translationKey || props.type.toUpperCase();
});
const deleteEntity = async payload => {
if (!payload) return;

View File

@@ -0,0 +1,73 @@
<script setup>
import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
authType: {
type: String,
required: true,
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
},
});
const { t } = useI18n();
const authConfig = defineModel('authConfig', {
type: Object,
default: () => ({}),
});
watch(
() => props.authType,
() => {
authConfig.value = {};
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<Input
v-if="authType === 'bearer'"
v-model="authConfig.token"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
"
/>
<template v-else-if="authType === 'basic'">
<Input
v-model="authConfig.username"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER')
"
/>
<Input
v-model="authConfig.password"
type="password"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER')
"
/>
</template>
<template v-else-if="authType === 'api_key'">
<Input
v-model="authConfig.name"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER')
"
/>
<Input
v-model="authConfig.key"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER')
"
/>
</template>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import CustomToolForm from './CustomToolForm.vue';
const props = defineProps({
selectedTool: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const updateTool = toolDetails =>
store.dispatch('captainCustomTools/update', {
id: props.selectedTool.id,
...toolDetails,
});
const i18nKey = computed(
() => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}`
);
const createTool = toolDetails =>
store.dispatch('captainCustomTools/create', toolDetails);
const handleSubmit = async updatedTool => {
try {
if (props.type === 'edit') {
await updateTool(updatedTool);
} else {
await createTool(updatedTool);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
width="2xl"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<CustomToolForm
:mode="type"
:tool="selectedTool"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
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 Policy from 'dashboard/components/policy.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
authType: {
type: String,
default: 'none',
},
updatedAt: {
type: Number,
required: true,
},
createdAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const timestamp = computed(() =>
dynamicTime(props.updatedAt || props.createdAt)
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const authTypeLabel = computed(() => {
return t(
`CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}`
);
});
</script>
<template>
<CardLayout class="relative">
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
{{ title }}
</span>
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAction($event)"
/>
</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"
>
{{ description }}
</span>
<span
v-if="authType !== 'none'"
class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-lucide-lock text-base" />
{{ authTypeLabel }}
</span>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ timestamp }}
</span>
</div>
</CardLayout>
</template>

View File

@@ -0,0 +1,271 @@
<script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ParamRow from './ParamRow.vue';
import AuthConfig from './AuthConfig.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
tool: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainCustomTools/getUIFlags'),
};
const initialState = {
title: '',
description: '',
endpoint_url: '',
http_method: 'GET',
request_template: '',
response_template: '',
auth_type: 'none',
auth_config: {},
param_schema: [],
};
const state = reactive({ ...initialState });
// Populate form when in edit mode
watch(
() => props.tool,
newTool => {
if (props.mode === 'edit' && newTool && newTool.id) {
state.title = newTool.title || '';
state.description = newTool.description || '';
state.endpoint_url = newTool.endpoint_url || '';
state.http_method = newTool.http_method || 'GET';
state.request_template = newTool.request_template || '';
state.response_template = newTool.response_template || '';
state.auth_type = newTool.auth_type || 'none';
state.auth_config = newTool.auth_config || {};
state.param_schema = newTool.param_schema || [];
}
},
{ immediate: true }
);
const DEFAULT_PARAM = {
name: '',
type: 'string',
description: '',
required: false,
};
const validationRules = {
title: { required },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
};
const httpMethodOptions = computed(() => [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
]);
const authTypeOptions = computed(() => [
{ value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') },
{ value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') },
{ value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') },
{
value: 'api_key',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
},
]);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() =>
props.mode === 'edit'
? formState.uiFlags.value.updatingItem
: formState.uiFlags.value.creatingItem
);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'),
}));
const paramsRef = useTemplateRef('paramsRef');
const isParamsValid = () => {
if (!paramsRef.value || paramsRef.value.length === 0) {
return true;
}
return paramsRef.value.every(param => param.validate());
};
const removeParam = index => {
state.param_schema.splice(index, 1);
};
const addParam = () => {
state.param_schema.push({ ...DEFAULT_PARAM });
};
const handleCancel = () => emit('cancel');
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid || !isParamsValid()) {
return;
}
emit('submit', state);
};
</script>
<template>
<form
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
@submit.prevent="handleSubmit"
>
<Input
v-model="state.title"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')"
:rows="2"
/>
<div class="flex gap-2">
<div class="flex flex-col gap-1 w-28">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }}
</label>
<ComboBox
v-model="state.http_method"
:options="httpMethodOptions"
class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]"
/>
</div>
<Input
v-model="state.endpoint_url"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')"
:message="formErrors.endpoint_url"
:message-type="formErrors.endpoint_url ? 'error' : 'info'"
class="flex-1"
/>
</div>
<div class="flex flex-col gap-1">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }}
</label>
<ComboBox
v-model="state.auth_type"
:options="authTypeOptions"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<AuthConfig
v-model:auth-config="state.auth_config"
:auth-type="state.auth_type"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
</label>
<p class="text-xs text-n-slate-11 -mt-1">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }}
</p>
<ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none">
<ParamRow
v-for="(param, index) in state.param_schema"
:key="index"
ref="paramsRef"
v-model:name="param.name"
v-model:type="param.type"
v-model:description="param.description"
v-model:required="param.required"
@remove="removeParam(index)"
/>
</ul>
<Button
type="button"
sm
ghost
blue
icon="i-lucide-plus"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')"
@click="addParam"
/>
</div>
<TextArea
v-if="state.http_method === 'POST'"
v-model="state.request_template"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')"
:rows="4"
class="[&_textarea]:font-mono"
/>
<TextArea
v-model="state.response_template"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER')
"
:rows="4"
class="[&_textarea]:font-mono"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="
t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')
"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,113 @@
<script setup>
import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const name = defineModel('name', {
type: String,
required: true,
});
const type = defineModel('type', {
type: String,
required: true,
});
const description = defineModel('description', {
type: String,
default: '',
});
const required = defineModel('required', {
type: Boolean,
default: false,
});
const paramTypeOptions = computed(() => [
{ value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') },
{ value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') },
{
value: 'boolean',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'),
},
{ value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') },
{ value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') },
]);
const validationError = computed(() => {
if (!name.value || name.value.trim() === '') {
return 'PARAM_NAME_REQUIRED';
}
return null;
});
watch([name, type, description, required], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
:class="{
'animate-wiggle border-n-ruby-9': showErrors && validationError,
}"
>
<div class="flex flex-col flex-1 gap-3">
<div class="grid grid-cols-3 gap-2">
<Input
v-model="name"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')"
class="col-span-2"
/>
<ComboBox
v-model="type"
:options="paramTypeOptions"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="description"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER')
"
/>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="required" />
<span class="text-sm text-n-slate-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }}
</span>
</label>
</div>
<Button
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click.stop="emit('remove')"
/>
</div>
<span
v-if="showErrors && validationError"
class="block mt-1 text-sm text-n-ruby-11"
>
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
</span>
</li>
</template>

View File

@@ -0,0 +1,29 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="min-h-[600px]" />
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>

View File

@@ -45,7 +45,7 @@ const activeAssistantLabel = computed(() => {
/>
</template>
<DropdownBody class="bottom-9 min-w-64 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="assistant in assistants"
:key="assistant.id"

View File

@@ -91,7 +91,7 @@ const updateSelected = newValue => {
:class="dropdownPosition"
strong
>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.value"

View File

@@ -123,7 +123,7 @@ const toggleOption = option => {
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.id"

View File

@@ -124,7 +124,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownSection class="[&>ul]:max-h-80">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"

View File

@@ -1,8 +1,10 @@
<script setup>
import { defineProps, computed } from 'vue';
import { defineProps, computed, reactive } from 'vue';
import Message from './Message.vue';
import { MESSAGE_TYPES } from './constants.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useMapGetter } from 'dashboard/composables/store.js';
import MessageApi from 'dashboard/api/inbox/message.js';
/**
* Props definition for the component
@@ -43,6 +45,48 @@ const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
/**
* Fetches a specific message from the API by trying to get messages around it
* @param {number} messageId - The ID of the message to fetch
* @param {number} conversationId - The ID of the conversation
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
}
};
/**
* Determines if a message should be grouped with the next message
* @param {Number} index - Index of the current message
@@ -90,10 +134,26 @@ const getInReplyToMessage = parentMessage => {
if (!inReplyToMessageId) return null;
// Find in-reply-to message in the messages prop
const replyMessage = props.messages?.find(
message => message.id === inReplyToMessageId
);
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
);
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
}
// If still not found and we have conversation context, fetch it
if (!replyMessage && currentChat.value?.id) {
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
return null; // Let UI handle loading state
}
return replyMessage ? useCamelCase(replyMessage) : null;
};

View File

@@ -5,9 +5,9 @@ import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
@@ -47,6 +47,13 @@ const originalEmailHtml = computed(
originalEmailText.value
);
const hasEmailContent = computed(() => {
return (
contentAttributes?.value?.email?.textContent?.full ||
contentAttributes?.value?.email?.htmlContent?.full
);
});
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
@@ -137,7 +144,7 @@ const handleSeeOriginal = () => {
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
v-if="isOutgoing && content && !hasEmailContent"
class="text-n-slate-12"
:content="messageContent"
/>

View File

@@ -232,6 +232,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_responses_index'),
},
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
to: accountScopedRoute('captain_tools_index'),
},
],
},
{

View File

@@ -76,7 +76,7 @@ function changeAvailabilityStatus(availability) {
</script>
<template>
<DropdownSection>
<DropdownSection class="[&>ul]:overflow-visible">
<div class="grid gap-0">
<DropdownItem preserve-open>
<div class="flex-grow flex items-center gap-1">

View File

@@ -118,6 +118,14 @@ export default {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -125,6 +133,7 @@ export default {
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -249,6 +258,11 @@ export default {
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
quotedReplyToggleTooltip() {
return this.quotedReplyEnabled
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
@@ -339,6 +353,16 @@ export default {
sm
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
quotedEmailText: {
type: String,
required: true,
},
previewText: {
type: String,
required: true,
},
});
const emit = defineEmits(['toggle']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const formattedQuotedEmailText = computed(() => {
if (!props.quotedEmailText) {
return '';
}
return formatMessage(props.quotedEmailText, false, false, true);
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<div class="mt-2">
<div
class="relative rounded-md px-3 py-2 text-xs text-n-slate-12 bg-n-slate-3 dark:bg-n-solid-3"
>
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
<NextButton
v-tooltip="
isExpanded
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.COLLAPSE')
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.EXPAND')
"
ghost
slate
xs
:icon="isExpanded ? 'i-lucide-minimize' : 'i-lucide-maximize'"
@click="toggleExpand"
/>
<NextButton
v-tooltip="t('CONVERSATION.REPLYBOX.QUOTED_REPLY.REMOVE_PREVIEW')"
ghost
slate
xs
icon="i-lucide-x"
@click="emit('toggle')"
/>
</div>
<div
v-dompurify-html="formattedQuotedEmailText"
class="w-full max-w-none break-words prose prose-sm dark:prose-invert cursor-pointer ltr:pr-8 rtl:pl-8"
:class="{
'line-clamp-1': !isExpanded,
'max-h-60 overflow-y-auto': isExpanded,
}"
:title="previewText"
@click="toggleExpand"
/>
</div>
</div>
</template>

View File

@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
@@ -16,6 +17,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
@@ -32,6 +34,12 @@ import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import {
extractQuotedEmailText,
buildQuotedEmailHeader,
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
@@ -65,6 +73,7 @@ export default {
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -80,6 +89,8 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
@@ -89,6 +100,8 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
};
},
@@ -130,6 +143,8 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
@@ -367,6 +382,51 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
return false;
}
return !!this.fetchQuotedReplyFlagFromUISettings(this.channelType);
},
lastEmailWithQuotedContent() {
if (!this.isAnEmailChannel) {
return null;
}
const lastEmail = this.lastEmail;
if (!lastEmail || lastEmail.private) {
return null;
}
return lastEmail;
},
quotedEmailText() {
return extractQuotedEmailText(this.lastEmailWithQuotedContent);
},
quotedEmailPreviewText() {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
},
shouldShowQuotedPreview() {
return (
this.shouldShowQuotedReplyToggle &&
this.quotedReplyPreference &&
!!this.quotedEmailText
);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -516,6 +576,36 @@ export default {
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
}
const nextValue = !this.quotedReplyPreference;
this.setQuotedReplyFlagForInbox(this.channelType, nextValue);
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
);
},
getMessageWithQuotedEmailText(message) {
if (!this.shouldIncludeQuotedEmail()) {
return message;
}
const quotedText = this.quotedEmailText || '';
const header = buildQuotedEmailHeader(
this.lastEmailWithQuotedContent,
this.currentContact,
this.inbox
);
return appendQuotedTextToMessage(message, quotedText, header);
},
resetRecorderAndClearAttachments() {
// Reset audio recorder UI state
this.resetAudioRecorderInput();
@@ -965,9 +1055,11 @@ export default {
return multipleMessagePayload;
},
getMessagePayload(message) {
const messageWithQuote = this.getMessageWithQuotedEmailText(message);
let messagePayload = {
conversationId: this.currentChat.id,
message,
message: messageWithQuote,
private: this.isPrivate,
sender: this.sender,
};
@@ -995,7 +1087,6 @@ export default {
if (this.toEmails && !this.isOnPrivateNote) {
messagePayload.toEmails = this.toEmails;
}
return messagePayload;
},
setCcEmails(value) {
@@ -1160,6 +1251,12 @@ export default {
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
@@ -1195,6 +1292,8 @@ export default {
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
@@ -1206,6 +1305,7 @@ export default {
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<WhatsappTemplates
:inbox-id="inbox.id"

View File

@@ -32,7 +32,10 @@ export default {
value: {
required,
isEqual(value) {
return value === this.confirmValue;
// Trim whitespace from both input and target values
const normalizedInput = (value || '').trim();
const normalizedTarget = (this.confirmValue || '').trim();
return normalizedInput === normalizedTarget;
},
},
},

View File

@@ -13,6 +13,7 @@ const getUISettingsMock = ref({
conversation_sidebar_items_order: DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
vi.mock('dashboard/composables/store', () => ({
@@ -37,6 +38,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
});
@@ -51,6 +53,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -65,6 +68,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -100,6 +104,7 @@ describe('useUISettings', () => {
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
email_signature_enabled: true,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -109,6 +114,26 @@ describe('useUISettings', () => {
expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined);
});
it('sets quoted reply flag for inbox correctly', () => {
const { setQuotedReplyFlagForInbox } = useUISettings();
setQuotedReplyFlagForInbox('Channel::Email', false);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: false,
},
});
});
it('fetches quoted reply flag from UI settings correctly', () => {
const { fetchQuotedReplyFlagFromUISettings } = useUISettings();
expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => {
getUISettingsMock.value.enter_to_send_enabled = false;
const { isEditorHotKeyEnabled } = useUISettings();

View File

@@ -87,6 +87,13 @@ const setSignatureFlagForInbox = (channelType, value, updateUISettings) => {
updateUISettings({ [`${slugifiedChannel}_signature_enabled`]: value });
};
const setQuotedReplyFlagForInbox = (channelType, value, updateUISettings) => {
if (!channelType) return;
const slugifiedChannel = slugifyChannel(channelType);
updateUISettings({ [`${slugifiedChannel}_quoted_reply_enabled`]: value });
};
/**
* Fetches the signature flag for a specific channel type from UI settings.
* @param {string} channelType - The type of the channel.
@@ -100,6 +107,13 @@ const fetchSignatureFlagFromUISettings = (channelType, uiSettings) => {
return uiSettings.value[`${slugifiedChannel}_signature_enabled`];
};
const fetchQuotedReplyFlagFromUISettings = (channelType, uiSettings) => {
if (!channelType) return false;
const slugifiedChannel = slugifyChannel(channelType);
return uiSettings.value[`${slugifiedChannel}_quoted_reply_enabled`];
};
/**
* Checks if a specific editor hotkey is enabled.
* @param {string} key - The key to check.
@@ -147,6 +161,10 @@ export function useUISettings() {
setSignatureFlagForInbox(channelType, value, updateUISettings),
fetchSignatureFlagFromUISettings: channelType =>
fetchSignatureFlagFromUISettings(channelType, uiSettings),
setQuotedReplyFlagForInbox: (channelType, value) =>
setQuotedReplyFlagForInbox(channelType, value, updateUISettings),
fetchQuotedReplyFlagFromUISettings: channelType =>
fetchQuotedReplyFlagFromUISettings(channelType, uiSettings),
isEditorHotKeyEnabled: key => isEditorHotKeyEnabled(key, uiSettings),
};
}

View File

@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
};
export const PREMIUM_FEATURES = [
@@ -48,5 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
];

View File

@@ -145,3 +145,34 @@ export const extractFilenameFromUrl = url => {
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
* @param {string} domains - The comma/newline separated list of domains
* @returns {string} - The normalized list of domains
* - Converts newlines to commas
* - Trims whitespace
* - Lowercases entries
* - Removes empty values
* - De-duplicates while preserving original order
*/
export const sanitizeAllowedDomains = domains => {
if (!domains) return '';
const tokens = domains
.replace(/\r\n/g, '\n')
.replace(/\s*\n\s*/g, ',')
.split(',')
.map(d => d.trim().toLowerCase())
.filter(d => d.length > 0);
// De-duplicate while preserving order using Set and filter index
const seen = new Set();
const unique = tokens.filter(d => {
if (seen.has(d)) return false;
seen.add(d);
return true;
});
return unique.join(',');
};

View File

@@ -10,6 +10,8 @@ const QUOTE_INDICATORS = [
'[class*="Quote"]',
];
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
@@ -36,6 +38,8 @@ export class EmailQuoteExtractor {
});
});
this.removeTrailingBlockquote(tempDiv);
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
@@ -62,6 +66,10 @@ export class EmailQuoteExtractor {
}
}
if (this.findTrailingBlockquote(tempDiv)) {
return true;
}
// Check for text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
return textNodeQuotes.length > 0;
@@ -123,4 +131,26 @@ export class EmailQuoteExtractor {
return null;
}
/**
* Remove fallback blockquote if it is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
*/
static removeTrailingBlockquote(rootElement) {
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
trailingBlockquote?.remove();
}
/**
* Locate a fallback blockquote that is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
* @returns {Element|null} The trailing blockquote element if present
*/
static findTrailingBlockquote(rootElement) {
const lastElement = rootElement.lastElementChild;
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
return lastElement;
}
return null;
}
}

View File

@@ -68,13 +68,17 @@ export const registerSubscription = (onSuccess = () => {}) => {
.then(() => {
onSuccess();
})
.catch(() => {
.catch(error => {
// eslint-disable-next-line no-console
console.error('Push subscription registration failed:', error);
useAlert('This browser does not support desktop notification');
});
};
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
// eslint-disable-next-line no-console
console.warn('Notification is not supported');
useAlert('This browser does not support desktop notification');
} else if (Notification.permission === 'granted') {
registerSubscription(onSuccess);

View File

@@ -0,0 +1,332 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
/**
* Extracts plain text from HTML content
* @param {string} html - HTML content to convert
* @returns {string} Plain text content
*/
export const extractPlainTextFromHtml = html => {
if (!html) {
return '';
}
if (typeof document === 'undefined') {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
};
/**
* Extracts sender name from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender name
*/
export const getEmailSenderName = (lastEmail, contact) => {
const senderName = lastEmail?.sender?.name;
if (senderName && senderName.trim()) {
return senderName.trim();
}
const contactName = contact?.name;
return contactName && contactName.trim() ? contactName.trim() : '';
};
/**
* Extracts sender email from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender email address
*/
export const getEmailSenderEmail = (lastEmail, contact) => {
const senderEmail = lastEmail?.sender?.email;
if (senderEmail && senderEmail.trim()) {
return senderEmail.trim();
}
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.from) && emailMeta.from.length > 0) {
const fromAddress = emailMeta.from[0];
if (fromAddress && fromAddress.trim()) {
return fromAddress.trim();
}
}
const contactEmail = contact?.email;
return contactEmail && contactEmail.trim() ? contactEmail.trim() : '';
};
/**
* Extracts date from email message
* @param {Object} lastEmail - Last email message object
* @returns {Date|null} Email date
*/
export const getEmailDate = lastEmail => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (emailMeta.date) {
const parsedDate = parseISO(emailMeta.date);
if (isValidDate(parsedDate)) {
return parsedDate;
}
}
const createdAt = lastEmail?.created_at;
if (createdAt) {
const timestamp = Number(createdAt);
if (!Number.isNaN(timestamp)) {
const milliseconds = timestamp > 1e12 ? timestamp : timestamp * 1000;
const derivedDate = new Date(milliseconds);
if (!Number.isNaN(derivedDate.getTime())) {
return derivedDate;
}
}
}
return null;
};
/**
* Formats date for quoted email header
* @param {Date} date - Date to format
* @returns {string} Formatted date string
*/
export const formatQuotedEmailDate = date => {
try {
return format(date, "EEE, MMM d, yyyy 'at' p");
} catch (error) {
const fallbackDate = new Date(date);
if (!Number.isNaN(fallbackDate.getTime())) {
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
}
}
return '';
};
/**
* Extracts inbox email address from last email message
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Inbox email address
*/
export const getInboxEmail = (lastEmail, inbox) => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.to) && emailMeta.to.length > 0) {
const toAddress = emailMeta.to[0];
if (toAddress && toAddress.trim()) {
return toAddress.trim();
}
}
const inboxEmail = inbox?.email;
return inboxEmail && inboxEmail.trim() ? inboxEmail.trim() : '';
};
/**
* Builds quoted email header from contact (for incoming messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromContact = (lastEmail, contact) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const senderEmail = getEmailSenderEmail(lastEmail, contact);
if (!quotedDate || !senderEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const senderName = getEmailSenderName(lastEmail, contact);
const hasName = !!senderName;
const contactLabel = hasName
? `${senderName} <${senderEmail}>`
: `<${senderEmail}>`;
return `On ${formattedDate} ${contactLabel} wrote:`;
};
/**
* Builds quoted email header from inbox (for outgoing messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromInbox = (lastEmail, inbox) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const inboxEmail = getInboxEmail(lastEmail, inbox);
if (!quotedDate || !inboxEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const inboxName = inbox?.name;
const hasName = !!inboxName;
const inboxLabel = hasName
? `${inboxName} <${inboxEmail}>`
: `<${inboxEmail}>`;
return `On ${formattedDate} ${inboxLabel} wrote:`;
};
/**
* Builds quoted email header based on message type
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeader = (lastEmail, contact, inbox) => {
if (!lastEmail) {
return '';
}
// MESSAGE_TYPE.OUTGOING = 1, MESSAGE_TYPE.INCOMING = 0
const isOutgoing = lastEmail.message_type === 1;
if (isOutgoing) {
return buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
}
return buildQuotedEmailHeaderFromContact(lastEmail, contact);
};
/**
* Formats text as markdown blockquote
* @param {string} text - Text to format
* @param {string} header - Optional header to prepend
* @returns {string} Formatted blockquote
*/
export const formatQuotedTextAsBlockquote = (text, header = '') => {
const normalizedLines = text
? String(text).replace(/\r\n/g, '\n').split('\n')
: [];
if (!header && !normalizedLines.length) {
return '';
}
const quotedLines = [];
if (header) {
quotedLines.push(`> ${header}`);
quotedLines.push('>');
}
normalizedLines.forEach(line => {
const trimmedLine = line.trimEnd();
quotedLines.push(trimmedLine ? `> ${trimmedLine}` : '>');
});
return quotedLines.join('\n');
};
/**
* Extracts quoted email text from last email message
* @param {Object} lastEmail - Last email message object
* @returns {string} Quoted email text
*/
export const extractQuotedEmailText = lastEmail => {
if (!lastEmail) {
return '';
}
const contentAttributes =
lastEmail.contentAttributes || lastEmail.content_attributes || {};
const emailContent = contentAttributes.email || {};
const textContent = emailContent.textContent || emailContent.text_content;
if (textContent?.reply) {
return textContent.reply;
}
if (textContent?.full) {
return textContent.full;
}
const htmlContent = emailContent.htmlContent || emailContent.html_content;
if (htmlContent?.reply) {
return extractPlainTextFromHtml(htmlContent.reply);
}
if (htmlContent?.full) {
return extractPlainTextFromHtml(htmlContent.full);
}
const fallbackContent =
lastEmail.content || lastEmail.processed_message_content || '';
return fallbackContent;
};
/**
* Truncates text for preview display
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length (default: 80)
* @returns {string} Truncated text
*/
export const truncatePreviewText = (text, maxLength = 80) => {
const preview = text.trim().replace(/\s+/g, ' ');
if (!preview) {
return '';
}
if (preview.length <= maxLength) {
return preview;
}
return `${preview.slice(0, maxLength - 3)}...`;
};
/**
* Appends quoted text to message
* @param {string} message - Original message
* @param {string} quotedText - Text to quote
* @param {string} header - Quote header
* @returns {string} Message with quoted text appended
*/
export const appendQuotedTextToMessage = (message, quotedText, header) => {
const baseMessage = message ? String(message) : '';
const quotedBlock = formatQuotedTextAsBlockquote(quotedText, header);
if (!quotedBlock) {
return baseMessage;
}
if (!baseMessage) {
return quotedBlock;
}
let separator = '\n\n';
if (baseMessage.endsWith('\n\n')) {
separator = '';
} else if (baseMessage.endsWith('\n')) {
separator = '\n';
}
return `${baseMessage}${separator}${quotedBlock}`;
};

View File

@@ -8,6 +8,7 @@ import {
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
sanitizeAllowedDomains,
} from '../URLHelper';
describe('#URL Helpers', () => {
@@ -318,4 +319,32 @@ describe('#URL Helpers', () => {
).toBe('file.doc');
});
});
describe('sanitizeAllowedDomains', () => {
it('returns empty string for falsy input', () => {
expect(sanitizeAllowedDomains('')).toBe('');
expect(sanitizeAllowedDomains(null)).toBe('');
expect(sanitizeAllowedDomains(undefined)).toBe('');
});
it('trims whitespace and converts newlines to commas', () => {
const input = ' example.com \n foo.bar\nbar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('handles Windows newlines and mixed spacing', () => {
const input = ' example.com\r\n\tfoo.bar , bar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('removes empty values from repeated commas', () => {
const input = ',,example.com,,foo.bar,,';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar');
});
it('lowercases entries and de-duplicates preserving order', () => {
const input = 'Example.com,FOO.bar,example.com,Bar.Baz,foo.BAR';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
const SAMPLE_EMAIL_HTML = `
<p>method</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:18 PM John <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Hi</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Yes, it is.</p>
<p>On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot &lt; shaneforwoot@gmail.com&gt; wrote:</p>
<blockquote>
<p>Hey</p>
<p>On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:</p>
<p>This is another quoted quoted text reply</p>
<p>This is nice</p>
<p>On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot &lt; &gt; shaneforwoot@gmail.com&gt; wrote:</p>
<p>Hey there, this is a reply from Chatwoot, notice the quoted text</p>
<p>Hey there</p>
<p>This is an email text, enjoy reading this</p>
<p>-- Shivam Mishra, Chatwoot</p>
</blockquote>
</blockquote>
</blockquote>
`;
const EMAIL_WITH_SIGNATURE = `
<p>Latest reply here.</p>
<p>Thanks,</p>
<p>Jane Doe</p>
<blockquote>
<p>On Mon, Sep 22, Someone wrote:</p>
<p>Previous reply content</p>
</blockquote>
`;
const EMAIL_WITH_FOLLOW_UP_CONTENT = `
<blockquote>
<p>Inline quote that should stay</p>
</blockquote>
<p>Internal note follows</p>
<p>Regards,</p>
`;
describe('EmailQuoteExtractor', () => {
it('removes blockquote-based quotes from the email body', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelectorAll('blockquote').length).toBe(0);
expect(container.textContent?.trim()).toBe('method');
expect(container.textContent).not.toContain(
'On Mon, Sep 29, 2025 at 5:18 PM'
);
});
it('keeps blockquote fallback when it is not the last top-level element', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
EMAIL_WITH_FOLLOW_UP_CONTENT
);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelector('blockquote')).not.toBeNull();
expect(container.lastElementChild?.tagName).toBe('P');
});
it('detects quote indicators in nested blockquotes', () => {
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
expect(result).toBe(true);
});
it('does not flag blockquotes that are followed by other elements', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe(
false
);
});
it('returns false when no quote indicators are present', () => {
const html = '<p>Plain content</p>';
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('removes trailing blockquotes while preserving trailing signatures', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
expect(cleanedHtml).toContain('<p>Thanks,</p>');
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
expect(cleanedHtml).not.toContain('<blockquote');
});
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
});

View File

@@ -0,0 +1,441 @@
import {
extractPlainTextFromHtml,
getEmailSenderName,
getEmailSenderEmail,
getEmailDate,
formatQuotedEmailDate,
getInboxEmail,
buildQuotedEmailHeader,
buildQuotedEmailHeaderFromContact,
buildQuotedEmailHeaderFromInbox,
formatQuotedTextAsBlockquote,
extractQuotedEmailText,
truncatePreviewText,
appendQuotedTextToMessage,
} from '../quotedEmailHelper';
describe('quotedEmailHelper', () => {
describe('extractPlainTextFromHtml', () => {
it('returns empty string for null or undefined', () => {
expect(extractPlainTextFromHtml(null)).toBe('');
expect(extractPlainTextFromHtml(undefined)).toBe('');
});
it('strips HTML tags and returns plain text', () => {
const html = '<p>Hello <strong>world</strong></p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello world');
});
it('handles complex HTML structure', () => {
const html = '<div><p>Line 1</p><p>Line 2</p></div>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
});
describe('getEmailSenderName', () => {
it('returns sender name from lastEmail', () => {
const lastEmail = { sender: { name: 'John Doe' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
it('returns contact name if sender name not available', () => {
const lastEmail = { sender: {} };
const contact = { name: 'Jane Smith' };
const result = getEmailSenderName(lastEmail, contact);
expect(result).toBe('Jane Smith');
});
it('returns empty string if neither available', () => {
const result = getEmailSenderName({}, {});
expect(result).toBe('');
});
it('trims whitespace from names', () => {
const lastEmail = { sender: { name: ' John Doe ' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
});
describe('getEmailSenderEmail', () => {
it('returns sender email from lastEmail', () => {
const lastEmail = { sender: { email: 'john@example.com' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
it('returns email from contentAttributes if sender email not available', () => {
const lastEmail = {
contentAttributes: {
email: { from: ['jane@example.com'] },
},
};
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('jane@example.com');
});
it('returns contact email as fallback', () => {
const lastEmail = {};
const contact = { email: 'contact@example.com' };
const result = getEmailSenderEmail(lastEmail, contact);
expect(result).toBe('contact@example.com');
});
it('trims whitespace from emails', () => {
const lastEmail = { sender: { email: ' john@example.com ' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
});
describe('getEmailDate', () => {
it('returns parsed date from email metadata', () => {
const lastEmail = {
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns date from created_at timestamp', () => {
const lastEmail = { created_at: 1705318200 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('handles millisecond timestamps', () => {
const lastEmail = { created_at: 1705318200000 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns null if no valid date found', () => {
const result = getEmailDate({});
expect(result).toBeNull();
});
});
describe('formatQuotedEmailDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15T10:30:00Z');
const result = formatQuotedEmailDate(date);
expect(result).toMatch(/Mon, Jan 15, 2024 at/);
});
it('returns empty string for invalid date', () => {
const result = formatQuotedEmailDate('invalid');
expect(result).toBe('');
});
});
describe('getInboxEmail', () => {
it('returns email from contentAttributes.email.to', () => {
const lastEmail = {
contentAttributes: {
email: { to: ['inbox@example.com'] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
it('returns inbox email as fallback', () => {
const lastEmail = {};
const inbox = { email: 'support@example.com' };
const result = getInboxEmail(lastEmail, inbox);
expect(result).toBe('support@example.com');
});
it('returns empty string if no email found', () => {
expect(getInboxEmail({}, {})).toBe('');
});
it('trims whitespace from emails', () => {
const lastEmail = {
contentAttributes: {
email: { to: [' inbox@example.com '] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
});
describe('buildQuotedEmailHeaderFromContact', () => {
it('builds complete header with name and email', () => {
const lastEmail = {
sender: { name: 'John Doe', email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('John Doe');
expect(result).toContain('john@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
sender: { email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('<john@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromContact(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromContact({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeaderFromInbox', () => {
it('builds complete header with inbox name and email', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support Team', email: 'support@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('Support Team');
expect(result).toContain('support@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['inbox@example.com'],
},
},
};
const inbox = { email: 'inbox@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('<inbox@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromInbox(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromInbox({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeader', () => {
it('uses inbox email for outgoing messages (message_type: 1)', () => {
const lastEmail = {
message_type: 1,
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'John Doe', email: 'john@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Support');
expect(result).toContain('support@example.com');
expect(result).not.toContain('John Doe');
});
it('uses contact email for incoming messages (message_type: 0)', () => {
const lastEmail = {
message_type: 0,
sender: { name: 'Jane Smith', email: 'jane@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'Jane Smith', email: 'jane@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Jane Smith');
expect(result).toContain('jane@example.com');
expect(result).not.toContain('Support');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeader(null, {}, {})).toBe('');
expect(buildQuotedEmailHeader({}, {}, {})).toBe('');
});
});
describe('formatQuotedTextAsBlockquote', () => {
it('formats single line text', () => {
const result = formatQuotedTextAsBlockquote('Hello world');
expect(result).toBe('> Hello world');
});
it('formats multi-line text', () => {
const text = 'Line 1\nLine 2\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2\n> Line 3');
});
it('includes header if provided', () => {
const result = formatQuotedTextAsBlockquote('Hello', 'Header text');
expect(result).toContain('> Header text');
expect(result).toContain('>\n> Hello');
});
it('handles empty lines correctly', () => {
const text = 'Line 1\n\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n>\n> Line 3');
});
it('returns empty string for empty input', () => {
expect(formatQuotedTextAsBlockquote('')).toBe('');
expect(formatQuotedTextAsBlockquote('', '')).toBe('');
});
it('handles Windows line endings', () => {
const text = 'Line 1\r\nLine 2';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2');
});
});
describe('extractQuotedEmailText', () => {
it('extracts text from textContent.reply', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { reply: 'Reply text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Reply text');
});
it('falls back to textContent.full', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { full: 'Full text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Full text');
});
it('extracts from htmlContent and converts to plain text', () => {
const lastEmail = {
contentAttributes: {
email: { htmlContent: { reply: '<p>HTML reply</p>' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('HTML reply');
});
it('uses fallback content if structured content not available', () => {
const lastEmail = { content: 'Fallback content' };
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Fallback content');
});
it('returns empty string for null or missing email', () => {
expect(extractQuotedEmailText(null)).toBe('');
expect(extractQuotedEmailText({})).toBe('');
});
});
describe('truncatePreviewText', () => {
it('returns full text if under max length', () => {
const text = 'Short text';
const result = truncatePreviewText(text, 80);
expect(result).toBe('Short text');
});
it('truncates text exceeding max length', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text, 80);
expect(result).toHaveLength(80);
expect(result).toContain('...');
});
it('collapses multiple spaces', () => {
const text = 'Text with spaces';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('trims whitespace', () => {
const text = ' Text with spaces ';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('returns empty string for empty input', () => {
expect(truncatePreviewText('')).toBe('');
expect(truncatePreviewText(' ')).toBe('');
});
it('uses default max length of 80', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text);
expect(result).toHaveLength(80);
});
});
describe('appendQuotedTextToMessage', () => {
it('appends quoted text to message', () => {
const message = 'My reply';
const quotedText = 'Original message';
const header = 'On date sender wrote:';
const result = appendQuotedTextToMessage(message, quotedText, header);
expect(result).toContain('My reply');
expect(result).toContain('> On date sender wrote:');
expect(result).toContain('> Original message');
});
it('returns only quoted text if message is empty', () => {
const result = appendQuotedTextToMessage('', 'Quoted', 'Header');
expect(result).toContain('> Header');
expect(result).toContain('> Quoted');
expect(result).not.toContain('\n\n\n');
});
it('returns message if no quoted text', () => {
const result = appendQuotedTextToMessage('Message', '', '');
expect(result).toBe('Message');
});
it('handles proper spacing with double newline', () => {
const result = appendQuotedTextToMessage('Message', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
it('does not add extra newlines if message already ends with newlines', () => {
const result = appendQuotedTextToMessage(
'Message\n\n',
'Quoted',
'Header'
);
expect(result).not.toContain('\n\n\n');
});
it('adds single newline if message ends with one newline', () => {
const result = appendQuotedTextToMessage('Message\n', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
});
});

View File

@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority"
"PRIORITY": "Priority",
"LABELS": "Labels"
}
}
}

View File

@@ -554,10 +554,12 @@
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "ሰብስብ",
"NO_NOTES": "ማስታወሻዎች የሉም፣ ከእውቂያው ዝርዝር ገፅ ላይ ማስታወሻዎችን መጨመር ይችላሉ።",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
}
},
"EMPTY_STATE": {

View File

@@ -227,6 +227,13 @@
"YES": "Send",
"CANCEL": "Cancel"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",

View File

@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}

View File

@@ -741,7 +741,8 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
},
"BRAND_COLOR": {
"LABEL": "Brand color"

View File

@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -272,8 +274,8 @@
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,9 +283,8 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -296,7 +297,9 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -604,8 +607,64 @@
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
@@ -617,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",

View File

@@ -752,6 +752,115 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"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.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "Password",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Type"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "Number",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "Required"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
@@ -761,6 +870,7 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {

View File

@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
"SUBMIT": "Login",
"SAML": {
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
}

View File

@@ -0,0 +1,106 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "Loading...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "Copy",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "Cancel",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "Download",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "Cancel",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}

View File

@@ -80,6 +80,11 @@
"NOTE": "Updating your password would reset your logins in multiple devices.",
"BTN_TEXT": "Change password"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -299,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -358,7 +364,8 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs"
"DOCS": "Read docs",
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -390,6 +397,77 @@
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Copied to clipboard",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",

View File

@@ -27,15 +27,20 @@
"LABEL": "Password",
"PLACEHOLDER": "Password",
"ERROR": "Password is too short.",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character."
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "Confirm password",
"PLACEHOLDER": "Confirm password",
"ERROR": "Password doesnot match."
"ERROR": "Passwords do not match."
},
"API": {
"SUCCESS_MESSAGE": "Registration Successfull",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"SUBMIT": "Create account",

View File

@@ -40,6 +40,7 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}

View File

@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "المكلَّف",
"TEAM_NAME": "الفريق",
"PRIORITY": "الأولوية"
"PRIORITY": "الأولوية",
"LABELS": "الوسوم"
}
}
}

View File

@@ -554,10 +554,12 @@
"WROTE": "كتب",
"YOU": "أنت",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
}
},
"EMPTY_STATE": {

View File

@@ -227,6 +227,13 @@
"YES": "إرسال",
"CANCEL": "إلغاء"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "ملاحظة خاصة: مرئية فقط لك ولأعضاء فريقك",

View File

@@ -5,6 +5,8 @@
"PLACEHOLDER": "بحث",
"EMPTY_STATE": "لم يتم العثور على النتائج"
},
"CLOSE": "أغلق"
"CLOSE": "أغلق",
"BETA": "تجريبي",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}

View File

@@ -741,7 +741,8 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
},
"BRAND_COLOR": {
"LABEL": "Brand color"

View File

@@ -5,6 +5,8 @@
"LEARN_MORE": "تعلم المزيد عن صناديق البريد",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "لا توجد صناديق وارد لقنوات تواصل مرتبطة بهذا الحساب."
},
@@ -272,8 +274,8 @@
},
"SUBMIT_BUTTON": "إنشاء قناة واتساب",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,9 +283,8 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -296,7 +297,9 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"API": {
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة واتساب"
@@ -604,8 +607,64 @@
"BUSINESS_HOURS": "ساعات العمل",
"WIDGET_BUILDER": "منشئ اللايف شات",
"BOT_CONFIGURATION": "اعدادات البوت",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "تقييم رضاء العملاء"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "مباشر"
}
}
},
"SETTINGS": "الإعدادات",
"FEATURES": {
"LABEL": "الخصائص",
@@ -617,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "كود \"الماسنجر\"",
"MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "وكيل الدعم",
"INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا",
"AGENT_ASSIGNMENT": "تعيين المحادثة",

View File

@@ -752,6 +752,115 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"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.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "نعم، احذف",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "الوصف",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "لا شيء",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "مفتاح API"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "كلمة المرور",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "النوع"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "العدد",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "مطلوب"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
@@ -761,6 +870,7 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "حذف",
"BULK_APPROVE": {

View File

@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "نسيت كلمة المرور؟",
"CREATE_NEW_ACCOUNT": "إنشاء حساب جديد",
"SUBMIT": "تسجيل الدخول"
"SUBMIT": "تسجيل الدخول",
"SAML": {
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
}

View File

@@ -0,0 +1,106 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "مفعل",
"DISABLED": "معطّل",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "جار التحميل...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "نسخ",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "إلغاء",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "تحميل",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "كلمة المرور",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "إلغاء",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "إلغاء",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}

View File

@@ -80,6 +80,11 @@
"NOTE": "تعديل كلمة المرور الخاصة بك سيعيد ضبط تسجيلات الدخول الخاصة بك في الأجهزة الأخرى.",
"BTN_TEXT": "تغيير كلمة المرور"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "رمز المصادقة",
"NOTE": "يمكن استخدام هذا رمز المصادقة إذا كنت تبني تطبيقات API للتكامل مع Chatwoot",
@@ -299,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "الرئيسية",
"AGENTS": "وكيل الدعم",
"AGENT_BOTS": "الروبوتات",
@@ -358,7 +364,8 @@
"INFO_TEXT": "السماح للنظام بوضع علامة غير متصل تلقائياً عند عدم استخدام التطبيق أو لوحة التحكم.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "قراءة المستندات"
"DOCS": "قراءة المستندات",
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "الفواتير",
@@ -390,6 +397,77 @@
},
"NO_BILLING_USER": "حساب الفوترة الخاص بك قيد الإعداد. الرجاء تحديث الصفحة وحاول مرة أخرى."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "تم نسخ الكود إلى الحافظة بنجاح",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "أوه! لم نتمكن من العثور على الحساب. الرجاء إنشاء حساب جديد للمتابعة.",
"NEW_ACCOUNT": "حساب جديد",

View File

@@ -27,15 +27,20 @@
"LABEL": "كلمة المرور",
"PLACEHOLDER": "كلمة المرور",
"ERROR": "كلمة المرور قصيرة جداً",
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد"
"IS_INVALID_PASSWORD": "يجب أن تحتوي كلمة المرور على الأقل على حرف كبير واحد وحرف صغير واحد ورقم واحد وحرف خاص واحد",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "تأكيد كلمة المرور",
"PLACEHOLDER": "تأكيد كلمة المرور",
"ERROR": "كلمة المرور غير متطابقة"
"ERROR": "كلمة المرور غير متطابقة."
},
"API": {
"SUCCESS_MESSAGE": "تم التسجيل بنجاح",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"SUBMIT": "إرسال",

View File

@@ -40,6 +40,7 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}

View File

@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority"
"PRIORITY": "Priority",
"LABELS": "Labels"
}
}
}

View File

@@ -554,10 +554,12 @@
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
}
},
"EMPTY_STATE": {

View File

@@ -227,6 +227,13 @@
"YES": "Send",
"CANCEL": "Cancel"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",

View File

@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}

View File

@@ -741,7 +741,8 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
},
"BRAND_COLOR": {
"LABEL": "Brand color"

View File

@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -272,8 +274,8 @@
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,9 +283,8 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -296,7 +297,9 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -604,8 +607,64 @@
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
@@ -617,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",

View File

@@ -752,6 +752,115 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"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.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "Password",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Type"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "Number",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "Required"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
@@ -761,6 +870,7 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {

View File

@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login"
"SUBMIT": "Login",
"SAML": {
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
}

View File

@@ -0,0 +1,106 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "Loading...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "Copy",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "Cancel",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "Download",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "Cancel",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}

View File

@@ -80,6 +80,11 @@
"NOTE": "Updating your password would reset your logins in multiple devices.",
"BTN_TEXT": "Change password"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -299,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -358,7 +364,8 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs"
"DOCS": "Read docs",
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -390,6 +397,77 @@
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Copied to clipboard",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",

View File

@@ -27,15 +27,20 @@
"LABEL": "Password",
"PLACEHOLDER": "Password",
"ERROR": "Password is too short.",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character."
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "Confirm password",
"PLACEHOLDER": "Confirm password",
"ERROR": "Password doesnot match."
"ERROR": "Passwords do not match."
},
"API": {
"SUCCESS_MESSAGE": "Registration Successfull",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"SUBMIT": "Create account",

View File

@@ -40,6 +40,7 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}

View File

@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
"PRIORITY": "Priority"
"PRIORITY": "Priority",
"LABELS": "Labels"
}
}
}

View File

@@ -554,10 +554,12 @@
"WROTE": "wrote",
"YOU": "You",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expand",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
}
},
"EMPTY_STATE": {

View File

@@ -227,6 +227,13 @@
"YES": "Send",
"CANCEL": "Отмени"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",

View File

@@ -5,6 +5,8 @@
"PLACEHOLDER": "Търсене",
"EMPTY_STATE": "Няма намерени резултати"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}

View File

@@ -741,7 +741,8 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
},
"BRAND_COLOR": {
"LABEL": "Brand color"

View File

@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -272,8 +274,8 @@
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,9 +283,8 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -296,7 +297,9 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"API": {
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
@@ -604,8 +607,64 @@
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
@@ -617,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Агенти",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",

View File

@@ -752,6 +752,115 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"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.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Описание",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "Password",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Тип"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "Number",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "Required"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",
@@ -761,6 +870,7 @@
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"SEARCH_PLACEHOLDER": "Search FAQs...",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Изтрий",
"BULK_APPROVE": {

View File

@@ -22,6 +22,20 @@
},
"FORGOT_PASSWORD": "Forgot your password?",
"CREATE_NEW_ACCOUNT": "Create new account",
"SUBMIT": "Login"
"SUBMIT": "Login",
"SAML": {
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
"WORK_EMAIL": {
"LABEL": "Work Email",
"PLACEHOLDER": "Enter your work email"
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
}

View File

@@ -0,0 +1,106 @@
{
"MFA_SETTINGS": {
"TITLE": "Two-Factor Authentication",
"SUBTITLE": "Secure your account with TOTP-based authentication",
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
"STATUS_TITLE": "Authentication Status",
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"STATUS_ENABLED": "Two-factor authentication is active",
"STATUS_ENABLED_DESC": "Your account is protected with an additional layer of security",
"ENABLE_BUTTON": "Enable Two-Factor Authentication",
"ENHANCE_SECURITY": "Enhance Your Account Security",
"ENHANCE_SECURITY_DESC": "Two-factor authentication adds an extra layer of security by requiring a verification code from your authenticator app in addition to your password.",
"SETUP": {
"STEP_NUMBER_1": "1",
"STEP_NUMBER_2": "2",
"STEP1_TITLE": "Scan QR Code with Your Authenticator App",
"STEP1_DESCRIPTION": "Use Google Authenticator, Authy, or any TOTP-compatible app",
"LOADING_QR": "Loading...",
"MANUAL_ENTRY": "Can't scan? Enter code manually",
"SECRET_KEY": "Secret Key",
"COPY": "Copy",
"ENTER_CODE": "Enter the 6-digit code from your authenticator app",
"ENTER_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify & Continue",
"CANCEL": "Отмени",
"ERROR_STARTING": "MFA not enabled. Please contact administrator.",
"INVALID_CODE": "Invalid verification code",
"SECRET_COPIED": "Secret key copied to clipboard",
"SUCCESS": "Two-factor authentication has been enabled successfully"
},
"BACKUP": {
"TITLE": "Save Your Backup Codes",
"DESCRIPTION": "Keep these codes safe. Each can be used once if you lose access to your authenticator",
"IMPORTANT": "Important:",
"IMPORTANT_NOTE": " Save these codes in a secure location. You won't be able to see them again.",
"DOWNLOAD": "Download",
"COPY_ALL": "Copy All",
"CONFIRM": "I have saved my backup codes in a secure location and understand that I won't be able to see them again",
"COMPLETE_SETUP": "Complete Setup",
"CODES_COPIED": "Backup codes copied to clipboard"
},
"MANAGEMENT": {
"BACKUP_CODES": "Backup Codes",
"BACKUP_CODES_DESC": "Generate new codes if you've lost or used your existing ones",
"REGENERATE": "Regenerate Backup Codes",
"DISABLE_MFA": "Disable 2FA",
"DISABLE_MFA_DESC": "Remove two-factor authentication from your account",
"DISABLE_BUTTON": "Disable Two-Factor Authentication"
},
"DISABLE": {
"TITLE": "Disable Two-Factor Authentication",
"DESCRIPTION": "You'll need to enter your password and a verification code to disable two-factor authentication.",
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Disable 2FA",
"CANCEL": "Отмени",
"SUCCESS": "Two-factor authentication has been disabled",
"ERROR": "Failed to disable MFA. Please check your credentials."
},
"REGENERATE": {
"TITLE": "Regenerate Backup Codes",
"DESCRIPTION": "This will invalidate your existing backup codes and generate new ones. Enter your verification code to continue.",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"CONFIRM": "Generate New Codes",
"CANCEL": "Отмени",
"NEW_CODES_TITLE": "New Backup Codes Generated",
"NEW_CODES_DESC": "Your old backup codes have been invalidated. Save these new codes in a secure location.",
"CODES_IMPORTANT": "Important:",
"CODES_IMPORTANT_NOTE": " Each code can only be used once. Save them before closing this window.",
"DOWNLOAD_CODES": "Download Codes",
"COPY_ALL_CODES": "Copy All Codes",
"CODES_SAVED": "I've Saved My Codes",
"SUCCESS": "New backup codes have been generated",
"ERROR": "Failed to regenerate backup codes"
}
},
"MFA_VERIFICATION": {
"TITLE": "Two-Factor Authentication",
"DESCRIPTION": "Enter your verification code to continue",
"AUTHENTICATOR_APP": "Authenticator App",
"BACKUP_CODE": "Backup Code",
"ENTER_OTP_CODE": "Enter 6-digit code from your authenticator app",
"ENTER_BACKUP_CODE": "Enter one of your backup codes",
"BACKUP_CODE_PLACEHOLDER": "000000",
"VERIFY_BUTTON": "Verify",
"TRY_ANOTHER_METHOD": "Try another verification method",
"CANCEL_LOGIN": "Cancel and return to login",
"HELP_TEXT": "Having trouble signing in?",
"LEARN_MORE": "Learn more about 2FA",
"HELP_MODAL": {
"TITLE": "Two-Factor Authentication Help",
"AUTHENTICATOR_TITLE": "Using an Authenticator App",
"AUTHENTICATOR_DESC": "Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit code shown for your account.",
"BACKUP_TITLE": "Using a Backup Code",
"BACKUP_DESC": "If you don't have access to your authenticator app, you can use one of the backup codes you saved when setting up 2FA. Each code can only be used once.",
"CONTACT_TITLE": "Need More Help?",
"CONTACT_DESC_CLOUD": "If you've lost access to both your authenticator app and backup codes, please reach out to Chatwoot support for assistance.",
"CONTACT_DESC_SELF_HOSTED": "If you've lost access to both your authenticator app and backup codes, please contact your administrator for assistance."
},
"VERIFICATION_FAILED": "Verification failed. Please try again."
}
}

View File

@@ -80,6 +80,11 @@
"NOTE": "Updating your password would reset your logins in multiple devices.",
"BTN_TEXT": "Change password"
},
"SECURITY_SECTION": {
"TITLE": "Security",
"NOTE": "Manage additional security features for your account.",
"MFA_BUTTON": "Manage Two-Factor Authentication"
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
@@ -299,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home",
"AGENTS": "Агенти",
"AGENT_BOTS": "Bots",
@@ -358,7 +364,8 @@
"INFO_TEXT": "Let the system automatically mark you offline when you aren't using the app or dashboard.",
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
},
"DOCS": "Read docs"
"DOCS": "Read docs",
"SECURITY": "Security"
},
"BILLING_SETTINGS": {
"TITLE": "Billing",
@@ -390,6 +397,77 @@
},
"NO_BILLING_USER": "Your billing account is being configured. Please refresh the page and try again."
},
"SECURITY_SETTINGS": {
"TITLE": "Security",
"DESCRIPTION": "Manage your account security settings.",
"LINK_TEXT": "Learn more about SAML SSO",
"SAML": {
"TITLE": "SAML SSO",
"NOTE": "Configure SAML single sign-on for your account. Users will authenticate through your identity provider instead of using email/password.",
"ACS_URL": {
"LABEL": "ACS URL",
"TOOLTIP": "Assertion Consumer Service URL - Configure this URL in your IdP as the destination for SAML responses"
},
"SSO_URL": {
"LABEL": "SSO URL",
"HELP": "The URL where SAML authentication requests will be sent",
"PLACEHOLDER": "https://your-idp.com/saml/sso"
},
"CERTIFICATE": {
"LABEL": "Signing certificate in PEM format",
"HELP": "The public certificate from your identity provider used to verify SAML responses",
"PLACEHOLDER": "-----BEGIN CERTIFICATE-----\nMIIC..."
},
"FINGERPRINT": {
"LABEL": "Fingerprint",
"TOOLTIP": "SHA-1 fingerprint of the certificate - Use this to verify the certificate in your IdP configuration"
},
"COPY_SUCCESS": "Code copied to clipboard successfully",
"SP_ENTITY_ID": {
"LABEL": "SP Entity ID",
"HELP": "Unique identifier for this application as a service provider (auto-generated).",
"TOOLTIP": "Unique identifier for Chatwoot as the Service Provider - Configure this in your IdP settings"
},
"IDP_ENTITY_ID": {
"LABEL": "Identity Provider Entity ID",
"HELP": "Unique identifier for your identity provider (usually found in IdP configuration)",
"PLACEHOLDER": "https://your-idp.com/saml"
},
"UPDATE_BUTTON": "Update SAML Settings",
"API": {
"SUCCESS": "SAML settings updated successfully",
"ERROR": "Failed to update SAML settings",
"ERROR_LOADING": "Failed to load SAML settings",
"DISABLED": "SAML settings disabled successfully"
},
"VALIDATION": {
"REQUIRED_FIELDS": "SSO URL, Identity Provider Entity ID, and Certificate are required fields",
"SSO_URL_ERROR": "Please enter a valid SSO URL",
"CERTIFICATE_ERROR": "Certificate is required",
"IDP_ENTITY_ID_ERROR": "Identity Provider Entity ID is required"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade to an Enterprise plan to access SAML single sign-on and other advanced security features.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"PAYWALL": {
"TITLE": "Upgrade to enable SAML SSO",
"AVAILABLE_ON": "The SAML SSO feature is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to SAML single sign-on and other advanced features.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ATTRIBUTE_MAPPING": {
"TITLE": "SAML Attribute Setup",
"DESCRIPTION": "The following attribute mappings must be configured in your identity provider"
},
"INFO_SECTION": {
"TITLE": "Service Provider Information",
"TOOLTIP": "Copy these values and configure them in your Identity Provider to establish the SAML connection"
}
}
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
"NEW_ACCOUNT": "New Account",

View File

@@ -27,15 +27,20 @@
"LABEL": "Password",
"PLACEHOLDER": "Password",
"ERROR": "Password is too short",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character"
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "Confirm Password",
"PLACEHOLDER": "Confirm Password",
"ERROR": "Password doesnot match"
"ERROR": "Passwords do not match."
},
"API": {
"SUCCESS_MESSAGE": "Registration Successfull",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "Не можа да се свърже с Woot сървър. Моля, опитайте отново по-късно"
},
"SUBMIT": "Create account",

View File

@@ -40,6 +40,7 @@
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"DOCUMENT_NAME_PLACEHOLDER": "Enter document filename (e.g., Invoice_2025.pdf)",
"BUTTON_PARAMETER": "Enter button parameter"
}
}

View File

@@ -177,7 +177,8 @@
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Cessionari",
"TEAM_NAME": "Equip",
"PRIORITY": "Prioritat"
"PRIORITY": "Prioritat",
"LABELS": "Etiquetes"
}
}
}

View File

@@ -554,10 +554,12 @@
"WROTE": "va escriure",
"YOU": "Tu",
"SAVE": "Save note",
"ADD_NOTE": "Add contact note",
"EXPAND": "Expandeix",
"COLLAPSE": "Collapse",
"NO_NOTES": "No notes, you can add notes from the contact details page.",
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
"EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above.",
"CONVERSATION_EMPTY_STATE": "There are no notes yet. Use the Add note button to create one."
}
},
"EMPTY_STATE": {

View File

@@ -227,6 +227,13 @@
"YES": "Envia",
"CANCEL": "Cancel·la"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Nota privada: Només és visible per tu i el vostre equip",

View File

@@ -5,6 +5,8 @@
"PLACEHOLDER": "Cercar",
"EMPTY_STATE": "No s'ha trobat agents"
},
"CLOSE": "Tanca"
"CLOSE": "Tanca",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}

View File

@@ -741,7 +741,8 @@
"LIVE_CHAT_WIDGET": {
"LABEL": "Live chat widget",
"PLACEHOLDER": "Select live chat widget",
"HELP_TEXT": "Select a live chat widget that will appear on your help center"
"HELP_TEXT": "Select a live chat widget that will appear on your help center",
"NONE_OPTION": "No widget"
},
"BRAND_COLOR": {
"LABEL": "Brand color"

View File

@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "La teva safata d'entrada està desconnectada. No rebràs missatges nous fins que no els tornis a autoritzar.",
"CLICK_TO_RECONNECT": "Fes clic aquí per tornar a connectar-te.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isnt complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "No hi ha cap safata d'entrada connectat a aquest compte."
},
@@ -272,8 +274,8 @@
},
"SUBMIT_BUTTON": "Crea un canal de WhatsApp",
"EMBEDDED_SIGNUP": {
"TITLE": "Quick Setup with Meta",
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"TITLE": "Quick setup with Meta",
"DESC": "Use the WhatsApp Embedded Signup flow to quickly connect new numbers. You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
"BENEFITS": {
"TITLE": "Benefits of Embedded Signup:",
"EASY_SETUP": "No manual configuration required",
@@ -281,9 +283,8 @@
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
},
"LEARN_MORE": {
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit",
"LINK_TEXT": "this link.",
"LINK_URL": "https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations"
"TEXT": "To learn more about integrated signup, pricing, and limitations, visit {link}.",
"LINK_TEXT": "this link"
},
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
"AUTH_PROCESSING": "Authenticating with Meta",
@@ -296,7 +297,9 @@
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
"SIGNUP_ERROR": "Signup error occurred",
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured",
"MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if youre a tech provider onboarding your own number, please use the {link} flow",
"MANUAL_LINK_TEXT": "manual setup flow"
},
"API": {
"ERROR_MESSAGE": "No hem pogut desar el canal WhatsApp"
@@ -604,8 +607,64 @@
"BUSINESS_HOURS": "Horari comercial",
"WIDGET_BUILDER": "Creador del widget",
"BOT_CONFIGURATION": "Configuracions del bot",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h",
"UNKNOWN": "Rating not available"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined",
"NON_EXISTS": "Non exists"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "En directe"
}
}
},
"SETTINGS": "Configuracions",
"FEATURES": {
"LABEL": "Característiques",
@@ -617,6 +676,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script del missatger",
"MESSENGER_SUB_HEAD": "Col·loca aquest botó dins de l'etiqueta body",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Afegir o eliminar agents d'aquesta safata d'entrada",
"AGENT_ASSIGNMENT": "Conversació Assignada",

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