Merge branch 'release/4.10.0'
This commit is contained in:
commit
0f914fa2ab
@ -76,7 +76,7 @@ jobs:
|
||||
bundle install
|
||||
|
||||
- node/install:
|
||||
node-version: '23.7'
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@ -117,7 +117,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '23.7'
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@ -148,7 +148,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '23.7'
|
||||
node-version: '24.13'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@ -218,6 +218,49 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
|
||||
# Install and configure OpenSearch
|
||||
- run:
|
||||
name: Install OpenSearch
|
||||
command: |
|
||||
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
|
||||
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
|
||||
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
|
||||
sudo mv opensearch-2.11.0 /opt/opensearch
|
||||
|
||||
- run:
|
||||
name: Configure and Start OpenSearch
|
||||
command: |
|
||||
# Configure OpenSearch for single-node testing
|
||||
cat > /opt/opensearch/config/opensearch.yml \<< EOF
|
||||
cluster.name: chatwoot-test
|
||||
node.name: node-1
|
||||
network.host: 0.0.0.0
|
||||
http.port: 9200
|
||||
discovery.type: single-node
|
||||
plugins.security.disabled: true
|
||||
EOF
|
||||
|
||||
# Set ownership and permissions
|
||||
sudo chown -R $USER:$USER /opt/opensearch
|
||||
|
||||
# Start OpenSearch in background
|
||||
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
|
||||
|
||||
- run:
|
||||
name: Wait for OpenSearch to be ready
|
||||
command: |
|
||||
echo "Waiting for OpenSearch to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
|
||||
echo "OpenSearch is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
echo "OpenSearch failed to start"
|
||||
exit 1
|
||||
|
||||
# Configure environment and database
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
@ -234,6 +277,7 @@ jobs:
|
||||
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
||||
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
||||
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
|
||||
|
||||
# Database setup
|
||||
- run:
|
||||
|
||||
@ -10,7 +10,7 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile.base
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '23.7.0'
|
||||
NODE_VERSION: '24.13.0'
|
||||
RUBY_VERSION: '3.4.4'
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: '1000'
|
||||
|
||||
@ -11,7 +11,7 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '23.7.0'
|
||||
NODE_VERSION: '24.13.0'
|
||||
RUBY_VERSION: '3.4.4'
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: '1000'
|
||||
|
||||
@ -274,3 +274,5 @@ AZURE_APP_SECRET=
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
|
||||
2
.github/workflows/frontend-fe.yml
vendored
2
.github/workflows/frontend-fe.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
|
||||
6
.github/workflows/run_foss_spec.yml
vendored
6
.github/workflows/run_foss_spec.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
@ -43,7 +43,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
|
||||
2
.github/workflows/size-limit.yml
vendored
2
.github/workflows/size-limit.yml
vendored
@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: pnpm
|
||||
|
||||
@ -441,7 +441,8 @@ GEM
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
httparty (0.21.0)
|
||||
httparty (0.24.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
@ -555,7 +556,8 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
|
||||
@ -8,7 +8,6 @@ ___
|
||||
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
|
||||
<p>
|
||||
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
|
||||
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
||||
@ -137,4 +136,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
|
||||
@ -1 +1 @@
|
||||
4.9.2
|
||||
4.10.0
|
||||
|
||||
@ -1 +1 @@
|
||||
3.4.3
|
||||
3.5.0
|
||||
|
||||
38
app/builders/v2/reports/channel_summary_builder.rb
Normal file
38
app/builders/v2/reports/channel_summary_builder.rb
Normal file
@ -0,0 +1,38 @@
|
||||
class V2::Reports::ChannelSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversations_by_channel_and_status
|
||||
account.conversations
|
||||
.joins(:inbox)
|
||||
.where(created_at: range)
|
||||
.group('inboxes.channel_type', 'conversations.status')
|
||||
.count
|
||||
.each_with_object({}) do |((channel_type, status), count), grouped|
|
||||
grouped[channel_type] ||= {}
|
||||
grouped[channel_type][status] = count
|
||||
end
|
||||
end
|
||||
|
||||
def build_channel_stats(status_counts)
|
||||
open_count = status_counts['open'] || 0
|
||||
resolved_count = status_counts['resolved'] || 0
|
||||
pending_count = status_counts['pending'] || 0
|
||||
snoozed_count = status_counts['snoozed'] || 0
|
||||
|
||||
{
|
||||
open: open_count,
|
||||
resolved: resolved_count,
|
||||
pending: pending_count,
|
||||
snoozed: snoozed_count,
|
||||
total: open_count + resolved_count + pending_count + snoozed_count
|
||||
}
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,76 @@
|
||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :authorize_account_update, only: [:update]
|
||||
|
||||
def show
|
||||
render json: preferences_payload
|
||||
end
|
||||
|
||||
def update
|
||||
params_to_update = captain_params
|
||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
||||
@current_account.save!
|
||||
|
||||
render json: preferences_payload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preferences_payload
|
||||
{
|
||||
providers: Llm::Models.providers,
|
||||
models: Llm::Models.models,
|
||||
features: features_with_account_preferences
|
||||
}
|
||||
end
|
||||
|
||||
def authorize_account_update
|
||||
authorize @current_account, :update?
|
||||
end
|
||||
|
||||
def captain_params
|
||||
permitted = {}
|
||||
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
|
||||
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
|
||||
permitted
|
||||
end
|
||||
|
||||
def merged_captain_models
|
||||
existing_models = @current_account.captain_models || {}
|
||||
existing_models.merge(permitted_captain_models)
|
||||
end
|
||||
|
||||
def merged_captain_features
|
||||
existing_features = @current_account.captain_features || {}
|
||||
existing_features.merge(permitted_captain_features)
|
||||
end
|
||||
|
||||
def permitted_captain_models
|
||||
params.require(:captain_models).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def permitted_captain_features
|
||||
params.require(:captain_features).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def features_with_account_preferences
|
||||
preferences = Current.account.captain_preferences
|
||||
account_features = preferences[:features] || {}
|
||||
account_models = preferences[:models] || {}
|
||||
|
||||
Llm::Models.feature_keys.index_with do |feature_key|
|
||||
config = Llm::Models.feature_config(feature_key)
|
||||
config.merge(
|
||||
enabled: account_features[feature_key] == true,
|
||||
selected: account_models[feature_key] || config[:default]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
|
||||
|
||||
@ -1,38 +1,27 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
|
||||
def show
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return render json: { template_exists: false } unless template
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
result = service.template_status
|
||||
|
||||
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
|
||||
render_template_status_response(status_result, template_name)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
if result[:service_error]
|
||||
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||
else
|
||||
render json: result
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
# Delete existing template even though we are using a new one.
|
||||
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||
delete_existing_template_if_needed
|
||||
|
||||
result = create_template_via_provider(template_params)
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
result = service.create_template(template_params)
|
||||
render_template_creation_result(result)
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def validate_whatsapp_channel
|
||||
return if @inbox.whatsapp?
|
||||
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
|
||||
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
|
||||
status: :bad_request
|
||||
end
|
||||
|
||||
@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def create_template_via_provider(template_params)
|
||||
template_config = {
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
}
|
||||
|
||||
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
elsif result[:service_error]
|
||||
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||
else
|
||||
render_failed_template_creation(result)
|
||||
end
|
||||
end
|
||||
|
||||
def render_successful_template_creation(result)
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name],
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: result[:language] || DEFAULT_LANGUAGE
|
||||
}
|
||||
}, status: :created
|
||||
if @inbox.twilio_whatsapp?
|
||||
render json: {
|
||||
template: {
|
||||
friendly_name: result[:friendly_name],
|
||||
content_sid: result[:content_sid],
|
||||
status: result[:status] || 'pending',
|
||||
language: result[:language] || 'en'
|
||||
}
|
||||
}, status: :created
|
||||
else
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name],
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: result[:language] || 'en'
|
||||
}
|
||||
}, status: :created
|
||||
end
|
||||
end
|
||||
|
||||
def render_failed_template_creation(result)
|
||||
@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delete_existing_template_if_needed
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return true if template.blank?
|
||||
|
||||
template_name = template['name']
|
||||
return true if template_name.blank?
|
||||
|
||||
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
return true unless template_status[:success]
|
||||
|
||||
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||
if deletion_result[:success]
|
||||
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||
true
|
||||
else
|
||||
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||
false
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def render_template_status_response(status_result, template_name)
|
||||
if status_result[:success]
|
||||
render json: {
|
||||
template_exists: true,
|
||||
template_name: template_name,
|
||||
status: status_result[:template][:status],
|
||||
template_id: status_result[:template][:id]
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
template_exists: false,
|
||||
error: 'Template not found'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_whatsapp_error(response_body)
|
||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||
|
||||
|
||||
@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||
{ survey_rules: [:operator, { values: [] }],
|
||||
template: [:name, :template_id, :created_at, :language] }] }]
|
||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
||||
@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
||||
search_type: search_type,
|
||||
params: params
|
||||
).perform
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
||||
end
|
||||
|
||||
def conversations_summary
|
||||
@report_data = generate_conversations_report
|
||||
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
|
||||
end
|
||||
|
||||
def conversation_traffic
|
||||
@report_data = generate_conversations_heatmap_report
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||
end
|
||||
|
||||
def channel
|
||||
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
|
||||
|
||||
render_report_with(V2::Reports::ChannelSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
|
||||
|
||||
since_time = Time.zone.at(permitted_params[:since].to_i)
|
||||
until_time = Time.zone.at(permitted_params[:until].to_i)
|
||||
(until_time - since_time) > 6.months
|
||||
end
|
||||
end
|
||||
|
||||
@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
|
||||
CHATWOOT_INBOX_TOKEN
|
||||
API_CHANNEL_NAME
|
||||
API_CHANNEL_THUMBNAIL
|
||||
ANALYTICS_TOKEN
|
||||
CLOUD_ANALYTICS_TOKEN
|
||||
DIRECT_UPLOADS_ENABLED
|
||||
MAXIMUM_FILE_UPLOAD_SIZE
|
||||
HCAPTCHA_SITE_KEY
|
||||
|
||||
@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def generate_conversations_report
|
||||
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
|
||||
summary = builder.summary
|
||||
|
||||
[generate_conversation_report_metrics(summary)]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_params(base_params)
|
||||
@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
|
||||
report[:resolved_conversations_count]
|
||||
]
|
||||
end
|
||||
|
||||
def generate_conversation_report_metrics(summary)
|
||||
[
|
||||
summary[:conversations_count],
|
||||
summary[:incoming_messages_count],
|
||||
summary[:outgoing_messages_count],
|
||||
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
|
||||
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
|
||||
summary[:resolutions_count],
|
||||
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
18
app/javascript/dashboard/api/captain/preferences.js
Normal file
18
app/javascript/dashboard/api/captain/preferences.js
Normal file
@ -0,0 +1,18 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainPreferences extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/preferences', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
updatePreferences(data) {
|
||||
return axios.put(this.url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainPreferences();
|
||||
@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
createCSATTemplate(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/conversations_summary`, {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||
return axios.get(`${this.url}/conversation_traffic`, {
|
||||
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||
|
||||
@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
contacts({ q, page = 1 }) {
|
||||
contacts({ q, page = 1, since, until }) {
|
||||
return axios.get(`${this.url}/contacts`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
conversations({ q, page = 1 }) {
|
||||
conversations({ q, page = 1, since, until }) {
|
||||
return axios.get(`${this.url}/conversations`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
messages({ q, page = 1 }) {
|
||||
messages({ q, page = 1, since, until, from, inboxId }) {
|
||||
return axios.get(`${this.url}/messages`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
from,
|
||||
inbox_id: inboxId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
articles({ q, page = 1 }) {
|
||||
articles({ q, page = 1, since, until }) {
|
||||
return axios.get(`${this.url}/articles`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
134
app/javascript/dashboard/api/specs/search.spec.js
Normal file
134
app/javascript/dashboard/api/specs/search.spec.js
Normal file
@ -0,0 +1,134 @@
|
||||
import searchAPI from '../search';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#SearchAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(searchAPI).toBeInstanceOf(ApiClient);
|
||||
expect(searchAPI).toHaveProperty('get');
|
||||
expect(searchAPI).toHaveProperty('contacts');
|
||||
expect(searchAPI).toHaveProperty('conversations');
|
||||
expect(searchAPI).toHaveProperty('messages');
|
||||
expect(searchAPI).toHaveProperty('articles');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('#get', () => {
|
||||
searchAPI.get({ q: 'test query' });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
|
||||
params: { q: 'test query' },
|
||||
});
|
||||
});
|
||||
|
||||
it('#contacts', () => {
|
||||
searchAPI.contacts({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
|
||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('#contacts with date filters', () => {
|
||||
searchAPI.contacts({
|
||||
q: 'test',
|
||||
page: 2,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
|
||||
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('#conversations', () => {
|
||||
searchAPI.conversations({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/search/conversations',
|
||||
{
|
||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#conversations with date filters', () => {
|
||||
searchAPI.conversations({
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/search/conversations',
|
||||
{
|
||||
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#messages', () => {
|
||||
searchAPI.messages({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
from: undefined,
|
||||
inbox_id: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#messages with all filters', () => {
|
||||
searchAPI.messages({
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
from: 'contact:42',
|
||||
inboxId: 10,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
from: 'contact:42',
|
||||
inbox_id: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#articles', () => {
|
||||
searchAPI.articles({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
|
||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('#articles with date filters', () => {
|
||||
searchAPI.articles({
|
||||
q: 'test',
|
||||
page: 2,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
|
||||
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -19,7 +19,7 @@ const handleClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-3 py-5"
|
||||
|
||||
@ -35,6 +35,10 @@ const sortMenus = [
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
|
||||
value: 'contacts_count',
|
||||
},
|
||||
];
|
||||
|
||||
const orderingMenus = [
|
||||
|
||||
@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
editorKey: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
@ -96,6 +97,7 @@ watch(
|
||||
]"
|
||||
>
|
||||
<WootEditor
|
||||
:editor-id="editorKey"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:focus-on-mount="focusOnMount"
|
||||
@ -152,6 +154,13 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,12 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachmentId = ref(0);
|
||||
const generateUid = () => {
|
||||
attachmentId.value += 1;
|
||||
return `attachment-${attachmentId.value}`;
|
||||
};
|
||||
|
||||
const uploadAttachment = ref(null);
|
||||
const isEmojiPickerOpen = ref(false);
|
||||
|
||||
@ -176,7 +182,8 @@ const onPaste = e => {
|
||||
.filter(file => file.size > 0)
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
// Add unique ID for clipboard-pasted files
|
||||
onFileUpload({ file, name, type, size, id: generateUid() });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@ -47,6 +48,8 @@ const emit = defineEmits([
|
||||
'createConversation',
|
||||
]);
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
showInboxesDropdown.value = true;
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
const stripMessageFormatting = channelType => {
|
||||
if (!state.message || !channelType) return;
|
||||
|
||||
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
const newChannelType = getEffectiveChannelType(channelType, medium);
|
||||
stripMessageFormatting(newChannelType);
|
||||
}
|
||||
|
||||
emit('updateTargetInbox', { ...rest, channelType, medium });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => {
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
state.message = '';
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
:selected-contact="selectedContact"
|
||||
:show-contacts-dropdown="showContactsDropdown"
|
||||
:is-loading="isLoading"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:contact-id="contactId"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:has-errors="validationStates.isContactInvalid"
|
||||
@search-contacts="handleContactSearch"
|
||||
@set-selected-contact="setSelectedContact"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
<InboxEmptyState v-if="showNoInboxAlert" />
|
||||
<InboxSelector
|
||||
v-else
|
||||
:target-inbox="targetInbox"
|
||||
:selected-contact="selectedContact"
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
/>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||
<ContactSelector
|
||||
:contacts="contacts"
|
||||
:selected-contact="selectedContact"
|
||||
:show-contacts-dropdown="showContactsDropdown"
|
||||
:is-loading="isLoading"
|
||||
:is-creating-contact="isCreatingContact"
|
||||
:contact-id="contactId"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:has-errors="validationStates.isContactInvalid"
|
||||
@search-contacts="handleContactSearch"
|
||||
@set-selected-contact="setSelectedContact"
|
||||
@clear-selected-contact="clearSelectedContact"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
<InboxEmptyState v-if="showNoInboxAlert" />
|
||||
<InboxSelector
|
||||
v-else
|
||||
:target-inbox="targetInbox"
|
||||
:selected-contact="selectedContact"
|
||||
:show-inboxes-dropdown="showInboxesDropdown"
|
||||
:contactable-inboxes-list="contactableInboxesList"
|
||||
:has-errors="validationStates.isInboxInvalid"
|
||||
@update-inbox="removeTargetInbox"
|
||||
@toggle-dropdown="showInboxesDropdown = $event"
|
||||
@handle-inbox-action="handleInboxAction"
|
||||
/>
|
||||
|
||||
<EmailOptions
|
||||
v-if="inboxTypes.isEmail"
|
||||
v-model:cc-emails="state.ccEmails"
|
||||
v-model:bcc-emails="state.bccEmails"
|
||||
v-model:subject="state.subject"
|
||||
:contacts="contacts"
|
||||
:show-cc-emails-dropdown="showCcEmailsDropdown"
|
||||
:show-bcc-emails-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
:has-errors="validationStates.isSubjectInvalid"
|
||||
@search-cc-emails="searchCcEmails"
|
||||
@search-bcc-emails="searchBccEmails"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
<EmailOptions
|
||||
v-if="inboxTypes.isEmail"
|
||||
v-model:cc-emails="state.ccEmails"
|
||||
v-model:bcc-emails="state.bccEmails"
|
||||
v-model:subject="state.subject"
|
||||
:contacts="contacts"
|
||||
:show-cc-emails-dropdown="showCcEmailsDropdown"
|
||||
:show-bcc-emails-dropdown="showBccEmailsDropdown"
|
||||
:is-loading="isLoading"
|
||||
:has-errors="validationStates.isSubjectInvalid"
|
||||
@search-cc-emails="searchCcEmails"
|
||||
@search-bcc-emails="searchBccEmails"
|
||||
@update-dropdown="handleDropdownUpdate"
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
<MessageEditor
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
|
||||
@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
|
||||
<DropdownMenu
|
||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
:menu-items="contactableInboxesList"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
@action="emit('handleInboxAction', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
@ -24,14 +23,14 @@ const modelValue = defineModel({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
:key="editorKey"
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
|
||||
"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
|
||||
@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
@ -37,9 +38,13 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disableLocalFiltering: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@ -57,6 +62,7 @@ const flattenedMenuItems = computed(() => {
|
||||
});
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (props.disableLocalFiltering) return props.menuItems;
|
||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||
|
||||
return flattenedMenuItems.value.filter(item =>
|
||||
@ -69,7 +75,7 @@ const filteredMenuSections = computed(() => {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!searchQuery.value) {
|
||||
if (props.disableLocalFiltering || !searchQuery.value) {
|
||||
return props.menuSections;
|
||||
}
|
||||
|
||||
@ -89,6 +95,12 @@ const filteredMenuSections = computed(() => {
|
||||
.filter(section => section.items.length > 0);
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
@ -118,7 +130,7 @@ onMounted(() => {
|
||||
>
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
@ -130,6 +142,7 @@ onMounted(() => {
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -141,10 +154,23 @@ onMounted(() => {
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
|
||||
:class="showSearch ? 'top-10' : 'top-0'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="section.isLoading"
|
||||
class="flex items-center justify-center py-2"
|
||||
>
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!section.items.length && section.emptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{ section.emptyState }}
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
@ -235,5 +261,6 @@ onMounted(() => {
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
LABELS: 'labels',
|
||||
BROWSER_LANGUAGE: 'browser_language',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
REFERER: 'referer',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
} from './helper/filterHelper';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
/**
|
||||
@ -218,17 +217,6 @@ export function useConversationFilterContext() {
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
options: countries,
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
|
||||
@ -42,7 +42,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const allMessages = computed(() => {
|
||||
return useCamelCase(props.messages, { deep: true });
|
||||
return useCamelCase(props.messages, {
|
||||
deep: true,
|
||||
stopPaths: ['content_attributes.translations'],
|
||||
});
|
||||
});
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -17,6 +17,10 @@ const { attachment } = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showTranscribedText: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
@ -182,7 +186,7 @@ const downloadAudio = async () => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attachment.transcribedText"
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
<script setup>
|
||||
import { h, computed, onMounted } from 'vue';
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@ -16,7 +15,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
@ -55,14 +53,7 @@ const toggleShortcutModalFn = show => {
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
// We're using localStorage to store the expanded item in the sidebar
|
||||
// This helps preserve context when navigating between portal and dashboard layouts
|
||||
// and also when the user refreshes the page
|
||||
const expandedItem = useStorage(
|
||||
'next-sidebar-expanded-item',
|
||||
null,
|
||||
sessionStorage
|
||||
);
|
||||
const expandedItem = ref(null);
|
||||
|
||||
const setExpandedItem = name => {
|
||||
expandedItem.value = expandedItem.value === name ? null : name;
|
||||
@ -493,6 +484,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-briefcase',
|
||||
to: accountScopedRoute('general_settings_index'),
|
||||
},
|
||||
// {
|
||||
// name: 'Settings Captain',
|
||||
// label: t('SIDEBAR.CAPTAIN_AI'),
|
||||
// icon: 'i-woot-captain',
|
||||
// to: accountScopedRoute('captain_settings_index'),
|
||||
// },
|
||||
{
|
||||
name: 'Settings Agents',
|
||||
label: t('SIDEBAR.AGENTS'),
|
||||
@ -663,7 +660,6 @@ const menuItems = computed(() => {
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
/>
|
||||
<YearInReviewBanner />
|
||||
<SidebarChangelogCard
|
||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||
/>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, nextTick } from 'vue';
|
||||
import { computed, onMounted, watch, nextTick } from 'vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
@ -126,6 +126,16 @@ onMounted(async () => {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
hasActiveChild,
|
||||
hasNewActiveChild => {
|
||||
if (hasNewActiveChild && !isExpanded.value) {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import Auth from 'dashboard/api/auth';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@ -24,7 +22,6 @@ defineOptions({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
|
||||
@ -34,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const showYearInReviewModal = ref(false);
|
||||
|
||||
const bannerClosedKey = computed(() => {
|
||||
return `yir_closed_${accountId.value}_2025`;
|
||||
});
|
||||
|
||||
const isBannerClosed = computed(() => {
|
||||
return uiSettings.value?.[bannerClosedKey.value] === true;
|
||||
});
|
||||
|
||||
const showYearInReviewMenuItem = computed(() => {
|
||||
return isBannerClosed.value;
|
||||
});
|
||||
|
||||
const openYearInReviewModal = () => {
|
||||
showYearInReviewModal.value = true;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const closeYearInReviewModal = () => {
|
||||
showYearInReviewModal.value = false;
|
||||
};
|
||||
|
||||
const showChatSupport = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
@ -68,13 +42,6 @@ const showChatSupport = computed(() => {
|
||||
|
||||
const menuItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
show: showYearInReviewMenuItem.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
|
||||
icon: 'i-lucide-gift',
|
||||
click: openYearInReviewModal,
|
||||
},
|
||||
{
|
||||
show: showChatSupport.value,
|
||||
showOnCustomBrandedInstance: false,
|
||||
@ -190,9 +157,4 @@ const allowedMenuItems = computed(() => {
|
||||
</template>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
<YearInReviewModal
|
||||
:show="showYearInReviewModal"
|
||||
@close="closeYearInReviewModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { computed, ref, onMounted, nextTick, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
@ -31,20 +31,24 @@ const enableTransition = ref(false);
|
||||
const activeElement = computed(() => tabRefs.value[activeTab.value]);
|
||||
|
||||
const updateIndicator = () => {
|
||||
if (!activeElement.value) return;
|
||||
nextTick(() => {
|
||||
if (!activeElement.value) return;
|
||||
|
||||
indicatorStyle.value = {
|
||||
left: `${activeElement.value.offsetLeft}px`,
|
||||
width: `${activeElement.value.offsetWidth}px`,
|
||||
};
|
||||
indicatorStyle.value = {
|
||||
left: `${activeElement.value.offsetLeft}px`,
|
||||
width: `${activeElement.value.offsetWidth}px`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useResizeObserver(activeElement, () => {
|
||||
if (enableTransition.value || !activeElement.value) updateIndicator();
|
||||
useResizeObserver(activeElement, updateIndicator);
|
||||
|
||||
// Watch for prop/tabs changes to update indicator position
|
||||
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updateIndicator();
|
||||
nextTick(() => {
|
||||
enableTransition.value = true;
|
||||
});
|
||||
@ -66,7 +70,7 @@ const showDivider = index => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
|
||||
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
|
||||
>
|
||||
<div
|
||||
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
|
||||
|
||||
@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="filteredMenuItems"
|
||||
:is-searching="isLoading"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||
@action="handleDropdownAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -206,10 +206,14 @@ const emitDateRange = () => {
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDatePicker = () => {
|
||||
showDatePicker.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative font-inter">
|
||||
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
|
||||
<DatePickerButton
|
||||
:selected-start-date="selectedStartDate"
|
||||
:selected-end-date="selectedEndDate"
|
||||
|
||||
@ -38,7 +38,7 @@ const toggleShowMore = () => {
|
||||
<button
|
||||
v-if="text.length > limit"
|
||||
class="text-n-brand !p-0 !border-0 align-top"
|
||||
@click="toggleShowMore"
|
||||
@click.stop="toggleShowMore"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
|
||||
@ -55,6 +55,7 @@ import {
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedFormatting,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@ -131,8 +132,10 @@ const editorMenuOptions = computed(() => {
|
||||
|
||||
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||
const schema = editorSchema.value;
|
||||
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
|
||||
const sanitizedContent = stripUnsupportedFormatting(content, schema);
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(schema).parse(content),
|
||||
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
|
||||
plugins: buildEditor({
|
||||
schema,
|
||||
placeholder,
|
||||
@ -859,6 +862,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
|
||||
@ -41,6 +41,9 @@ const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
|
||||
@ -7,9 +7,7 @@ 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';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
@ -46,8 +44,8 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
@ -72,8 +70,6 @@ export default {
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
ResizableTextArea,
|
||||
CannedResponse,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@ -114,8 +110,6 @@ export default {
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
@ -148,9 +142,6 @@ export default {
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
this.inReplyTo?.id &&
|
||||
@ -409,19 +400,6 @@ export default {
|
||||
!!this.quotedEmailText
|
||||
);
|
||||
},
|
||||
showRichContentEditor() {
|
||||
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@ -464,25 +442,7 @@ export default {
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
},
|
||||
message(updatedMessage) {
|
||||
// Check if the message starts with a slash.
|
||||
const bodyWithoutSignature = removeSignature(
|
||||
updatedMessage,
|
||||
this.signatureToApply
|
||||
);
|
||||
const startsWithSlash = bodyWithoutSignature.startsWith('/');
|
||||
|
||||
// Determine if the user is potentially typing a slash command.
|
||||
// This is true if the message starts with a slash and the rich content editor is not active.
|
||||
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
|
||||
this.showMentions = this.hasSlashCommand;
|
||||
|
||||
// If a slash command is active, extract the command text after the slash.
|
||||
// If not, reset the mentionSearchKey.
|
||||
this.mentionSearchKey = this.hasSlashCommand
|
||||
? bodyWithoutSignature.substring(1)
|
||||
: '';
|
||||
|
||||
message() {
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
@ -532,20 +492,14 @@ export default {
|
||||
methods: {
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
if (this.isRichEditorEnabled) {
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
} else {
|
||||
this.addIntoEditor(
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
|
||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
@ -614,26 +568,14 @@ export default {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
)
|
||||
: removeSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@ -649,7 +591,6 @@ export default {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@ -694,14 +635,28 @@ export default {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) return;
|
||||
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
}
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
.filter(file => file.size > 0)
|
||||
.filter(file => {
|
||||
const isAllowed = isFileTypeAllowedForChannel(file, {
|
||||
channelType: this.channelType || this.inbox?.channel_type,
|
||||
medium: this.inbox?.medium,
|
||||
conversationType: this.conversationType,
|
||||
isInstagramChannel: this.isAnInstagramChannel,
|
||||
isOnPrivateNote: this.isOnPrivateNote,
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
useAlert(
|
||||
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
|
||||
fileName: file.name,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return isAllowed;
|
||||
})
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file });
|
||||
@ -832,19 +787,15 @@ export default {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
}
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@ -868,52 +819,30 @@ export default {
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
return;
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
},
|
||||
clearEditorSelection() {
|
||||
this.updateEditorSelectionWith = '';
|
||||
},
|
||||
insertIntoTextEditor(text, selectionStart, selectionEnd) {
|
||||
const { message } = this;
|
||||
const newMessage =
|
||||
message.slice(0, selectionStart) +
|
||||
text +
|
||||
message.slice(selectionEnd, message.length);
|
||||
this.message = newMessage;
|
||||
},
|
||||
addIntoEditor(content) {
|
||||
if (this.showRichContentEditor) {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
}
|
||||
if (!this.showRichContentEditor) {
|
||||
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||
}
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
@ -948,9 +877,6 @@ export default {
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
hideMentions() {
|
||||
this.showMentions = false;
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
@ -1197,13 +1123,6 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
@ -1227,23 +1146,7 @@ export default {
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@pause="recordingAudioState = 'paused'"
|
||||
/>
|
||||
<ResizableTextArea
|
||||
v-else-if="!showRichContentEditor"
|
||||
ref="messageInput"
|
||||
v-model="message"
|
||||
class="rounded-none input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
@ -1369,10 +1272,6 @@ export default {
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
|
||||
textarea {
|
||||
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
@ -1392,9 +1291,4 @@ export default {
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -78,14 +78,6 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
attributeI18nKey: 'COUNTRY_NAME',
|
||||
inputType: 'search_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
attributeI18nKey: 'REFERER_LINK',
|
||||
@ -171,10 +163,6 @@ export const filterAttributeGroups = [
|
||||
key: 'browser_language',
|
||||
i18nKey: 'BROWSER_LANGUAGE',
|
||||
},
|
||||
{
|
||||
key: 'country_code',
|
||||
i18nKey: 'COUNTRY_NAME',
|
||||
},
|
||||
{
|
||||
key: 'referer',
|
||||
i18nKey: 'REFERER_LINK',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@ -15,6 +16,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['mentionSelect']);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const mentionsListContainerRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
@ -94,7 +97,7 @@ const variableKey = (item = {}) => {
|
||||
'text-n-slate-12': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.description }}
|
||||
{{ getPlainText(item.description) }}
|
||||
</p>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
import { ref } from 'vue';
|
||||
import { useTranslations } from '../useTranslations';
|
||||
import { selectTranslation } from '../useTranslations';
|
||||
|
||||
describe('useTranslations', () => {
|
||||
it('returns false and null when contentAttributes is null', () => {
|
||||
const contentAttributes = ref(null);
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
describe('selectTranslation', () => {
|
||||
it('returns null when translations is null', () => {
|
||||
expect(selectTranslation(null, 'en', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations are missing', () => {
|
||||
const contentAttributes = ref({});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
it('returns null when translations is empty', () => {
|
||||
expect(selectTranslation({}, 'en', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations is an empty object', () => {
|
||||
const contentAttributes = ref({ translations: {} });
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
it('returns first translation when no locale matches', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola' };
|
||||
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('returns true and correct translation content when translations exist', () => {
|
||||
const contentAttributes = ref({
|
||||
translations: { en: 'Hello' },
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(true);
|
||||
// Should return the first translation (en: 'Hello')
|
||||
expect(translationContent.value).toBe('Hello');
|
||||
it('returns translation matching agent locale', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
|
||||
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
|
||||
});
|
||||
|
||||
it('falls back to account locale when agent locale not found', () => {
|
||||
const translations = { en: 'Hello', zh_CN: '你好' };
|
||||
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
|
||||
});
|
||||
|
||||
it('returns first translation when both locales are undefined', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola' };
|
||||
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,25 @@
|
||||
import { computed } from 'vue';
|
||||
import { useUISettings } from './useUISettings';
|
||||
import { useAccount } from './useAccount';
|
||||
|
||||
/**
|
||||
* Select translation based on locale priority.
|
||||
* @param {Object} translations - Translations object with locale keys
|
||||
* @param {string} agentLocale - Agent's preferred locale
|
||||
* @param {string} accountLocale - Account's default locale
|
||||
* @returns {string|null} Selected translation or null
|
||||
*/
|
||||
export function selectTranslation(translations, agentLocale, accountLocale) {
|
||||
if (!translations || Object.keys(translations).length === 0) return null;
|
||||
|
||||
if (agentLocale && translations[agentLocale]) {
|
||||
return translations[agentLocale];
|
||||
}
|
||||
if (accountLocale && translations[accountLocale]) {
|
||||
return translations[accountLocale];
|
||||
}
|
||||
return translations[Object.keys(translations)[0]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to extract translation state/content from contentAttributes.
|
||||
@ -6,6 +27,9 @@ import { computed } from 'vue';
|
||||
* @returns {Object} { hasTranslations, translationContent }
|
||||
*/
|
||||
export function useTranslations(contentAttributes) {
|
||||
const { uiSettings } = useUISettings();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
if (!contentAttributes.value) return false;
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
|
||||
|
||||
const translationContent = computed(() => {
|
||||
if (!hasTranslations.value) return null;
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
return selectTranslation(
|
||||
contentAttributes.value.translations,
|
||||
uiSettings.value?.locale,
|
||||
currentAccount.value?.locale
|
||||
);
|
||||
});
|
||||
|
||||
return { hasTranslations, translationContent };
|
||||
|
||||
@ -140,6 +140,11 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||
},
|
||||
'Context::Plain': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
@ -232,8 +237,12 @@ export const MARKDOWN_PATTERNS = [
|
||||
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'link', // PM: link, eg: [text](url)
|
||||
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
|
||||
type: 'link', // PM: link
|
||||
patterns: [
|
||||
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
|
||||
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
|
||||
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
};
|
||||
|
||||
@ -43,6 +43,7 @@ export const FEATURE_FLAGS = {
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import posthog from 'posthog-js';
|
||||
import * as amplitude from '@amplitude/analytics-browser';
|
||||
|
||||
/**
|
||||
* AnalyticsHelper class to initialize and track user analytics
|
||||
@ -26,12 +26,10 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.init(this.analyticsToken, {
|
||||
api_host: 'https://app.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
amplitude.init(this.analyticsToken, {
|
||||
defaultTracking: false,
|
||||
});
|
||||
this.analytics = posthog;
|
||||
this.analytics = amplitude;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,20 +43,26 @@ export class AnalyticsHelper {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.id.toString(), {
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
});
|
||||
this.analytics.setUserId(`user-${this.user.id.toString()}`);
|
||||
|
||||
const identifyEvent = new amplitude.Identify();
|
||||
identifyEvent.set('email', this.user.email);
|
||||
identifyEvent.set('name', this.user.name);
|
||||
identifyEvent.set('avatar', this.user.avatar_url);
|
||||
this.analytics.identify(identifyEvent);
|
||||
|
||||
const { accounts, account_id: accountId } = this.user;
|
||||
const [currentAccount] = accounts.filter(
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group('company', currentAccount.id.toString(), {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
const groupId = `account-${currentAccount.id.toString()}`;
|
||||
|
||||
this.analytics.setGroup('company', groupId);
|
||||
|
||||
const groupIdentify = new amplitude.Identify();
|
||||
groupIdentify.set('name', currentAccount.name);
|
||||
this.analytics.groupIdentify('company', groupId, groupIdentify);
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,20 +76,21 @@ export class AnalyticsHelper {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
this.analytics.capture(eventName, properties);
|
||||
this.analytics.track(eventName, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the page views
|
||||
* @function
|
||||
* @param {Object} params - Page view properties
|
||||
* @param {string} pageName - Page name
|
||||
* @param {Object} [properties={}] - Page view properties
|
||||
*/
|
||||
page(params) {
|
||||
page(pageName, properties = {}) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.capture('$pageview', params);
|
||||
this.analytics.track('$pageview', { pageName, ...properties });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
init: vi.fn(),
|
||||
setUserId: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
setGroup: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
Identify: vi.fn(() => ({
|
||||
set: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('helperObject', () => {
|
||||
@ -22,12 +25,12 @@ describe('AnalyticsHelper', () => {
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize posthog with the correct token', async () => {
|
||||
it('should initialize amplitude with the correct token', async () => {
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not initialize posthog if token is not provided', async () => {
|
||||
it('should not initialize amplitude if token is not provided', async () => {
|
||||
analyticsHelper = new AnalyticsHelper();
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@ -36,10 +39,15 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('identify', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
analyticsHelper.analytics = {
|
||||
setUserId: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
setGroup: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should call identify on posthog with correct arguments', () => {
|
||||
it('should call setUserId and identify on amplitude with correct arguments', () => {
|
||||
analyticsHelper.identify({
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
@ -49,19 +57,18 @@ describe('AnalyticsHelper', () => {
|
||||
account_id: 1,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
});
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'1',
|
||||
{ name: 'Account 1' }
|
||||
expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith(
|
||||
'user-123'
|
||||
);
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalled();
|
||||
expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'account-1'
|
||||
);
|
||||
expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call identify on posthog without group', () => {
|
||||
it('should call identify on amplitude without group', () => {
|
||||
analyticsHelper.identify({
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
@ -71,10 +78,10 @@ describe('AnalyticsHelper', () => {
|
||||
account_id: 5,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
|
||||
expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call analytics.page if analytics is null', () => {
|
||||
it('should not call analytics methods if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.identify({});
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@ -83,27 +90,27 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: 123 };
|
||||
});
|
||||
|
||||
it('should call capture on posthog with correct arguments', () => {
|
||||
it('should call track on amplitude with correct arguments', () => {
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{ prop1: 'value1', prop2: 'value2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should call capture on posthog with default properties', () => {
|
||||
it('should call track on amplitude with default properties', () => {
|
||||
analyticsHelper.track('Test Event');
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call capture on posthog if analytics is not initialized', () => {
|
||||
it('should not call track on amplitude if analytics is not initialized', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@ -112,24 +119,25 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the capture method for pageview with the correct arguments', () => {
|
||||
const params = {
|
||||
name: 'Test page',
|
||||
url: '/test',
|
||||
it('should call the track method for pageview with the correct arguments', () => {
|
||||
const pageName = 'home';
|
||||
const properties = {
|
||||
path: '/test',
|
||||
name: 'home',
|
||||
};
|
||||
analyticsHelper.page(params);
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
analyticsHelper.page(pageName, properties);
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
params
|
||||
{ pageName: 'home', path: '/test', name: 'home' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call analytics.capture if analytics is null', () => {
|
||||
it('should not call analytics.track if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.page();
|
||||
analyticsHelper.page('home');
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,45 +34,35 @@ export function extractTextFromMarkdown(markdown) {
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
* Uses MARKDOWN_PATTERNS from editor constants.
|
||||
*
|
||||
* @param {string} markdown - markdown text to process
|
||||
* @param {string} channelType - The channel type to check supported formatting
|
||||
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
|
||||
* @returns {string} - The markdown with unsupported formatting removed
|
||||
*/
|
||||
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
export function stripUnsupportedMarkdown(
|
||||
markdown,
|
||||
channelType,
|
||||
cleanWhitespace = true
|
||||
) {
|
||||
if (!markdown) return '';
|
||||
|
||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||
const has = (arr, key) => arr.includes(key);
|
||||
const supported = [...marks, ...nodes];
|
||||
|
||||
// Define stripping rules: [condition, pattern, replacement]
|
||||
const rules = [
|
||||
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
|
||||
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
|
||||
// Match _text_ only at word boundaries (whitespace/string start/end)
|
||||
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
|
||||
[
|
||||
!has(marks, 'em'),
|
||||
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
|
||||
'$1',
|
||||
],
|
||||
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
|
||||
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
|
||||
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
|
||||
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
|
||||
];
|
||||
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
|
||||
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
|
||||
if (supported.includes(type)) return text;
|
||||
return patterns.reduce(
|
||||
(t, { pattern, replacement }) => t.replace(pattern, replacement),
|
||||
text
|
||||
);
|
||||
}, markdown);
|
||||
|
||||
const result = rules.reduce(
|
||||
(text, [shouldStrip, pattern, replacement]) =>
|
||||
shouldStrip ? text.replace(pattern, replacement) : text,
|
||||
markdown
|
||||
);
|
||||
if (!cleanWhitespace) return result;
|
||||
|
||||
// Clean whitespace for signatures
|
||||
return result
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
@ -173,7 +163,7 @@ export function getEffectiveChannelType(channelType, medium) {
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||
? stripUnsupportedMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
// if signature is already present, return body
|
||||
@ -186,27 +176,22 @@ export function appendSignature(body, signature, channelType) {
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
* Tries to find both the original signature and the stripped version.
|
||||
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
|
||||
*
|
||||
* @param {string} body - The body to remove the signature from.
|
||||
* @param {string} signature - The signature to remove.
|
||||
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
|
||||
* For Twilio channels, pass the result of getEffectiveChannelType().
|
||||
* @returns {string} - The body with the signature removed.
|
||||
*/
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
// Build unique list of signature variants to try
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
|
||||
: null;
|
||||
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||
|
||||
// Try signatures in order: original → channel-specific → fully stripped
|
||||
const signaturesToTry = [
|
||||
cleanedSignature,
|
||||
cleanSignature(signature),
|
||||
channelStripped,
|
||||
fullyStripped,
|
||||
cleanSignature(extractTextFromMarkdown(signature)),
|
||||
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||
|
||||
// Find the first matching signature
|
||||
@ -225,17 +210,12 @@ export function removeSignature(body, signature, channelType) {
|
||||
newBody = newBody.substring(0, signatureIndex).trimEnd();
|
||||
}
|
||||
|
||||
// let's find the delimiter and remove it
|
||||
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
|
||||
if (
|
||||
delimiterIndex !== -1 &&
|
||||
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
|
||||
) {
|
||||
// Remove delimiter if it's at the end
|
||||
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
|
||||
// if the delimiter is at the end, remove it
|
||||
newBody = newBody.substring(0, delimiterIndex);
|
||||
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
|
||||
}
|
||||
|
||||
// return the value
|
||||
return newBody;
|
||||
}
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@ -5,7 +5,7 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
stripUnsupportedSignatureMarkdown,
|
||||
stripUnsupportedMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
@ -145,25 +145,19 @@ describe('appendSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
describe('stripUnsupportedMarkdown', () => {
|
||||
const richSignature =
|
||||
'**Bold** _italic_ [link](http://example.com) ';
|
||||
|
||||
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Email'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).toContain('');
|
||||
});
|
||||
it('strips images but keeps bold/italic for Api channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Api'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('link'); // link text kept
|
||||
@ -171,20 +165,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
expect(result).not.toContain('; // image removed
|
||||
});
|
||||
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Telegram'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('strips all formatting for SMS channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Sms'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
|
||||
expect(result).toContain('Bold');
|
||||
expect(result).toContain('italic');
|
||||
expect(result).toContain('link');
|
||||
@ -194,8 +182,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
|
||||
});
|
||||
|
||||
describe('with cleanWhitespace parameter', () => {
|
||||
const textWithWhitespace =
|
||||
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
|
||||
|
||||
it('cleans whitespace when cleanWhitespace=true (default)', () => {
|
||||
const result = stripUnsupportedMarkdown(
|
||||
textWithWhitespace,
|
||||
'Channel::Api',
|
||||
true
|
||||
);
|
||||
expect(result).toBe(
|
||||
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
|
||||
);
|
||||
expect(result).not.toContain('\n\n');
|
||||
expect(result).not.toContain(' ');
|
||||
});
|
||||
|
||||
it('preserves whitespace when cleanWhitespace=false', () => {
|
||||
const result = stripUnsupportedMarkdown(
|
||||
textWithWhitespace,
|
||||
'Channel::Api',
|
||||
false
|
||||
);
|
||||
expect(result).toContain('\n\n');
|
||||
expect(result).toContain(' And spaces ');
|
||||
expect(result).toBe(
|
||||
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
|
||||
);
|
||||
});
|
||||
|
||||
it('strips formatting but preserves whitespace for messages', () => {
|
||||
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
|
||||
const result = stripUnsupportedMarkdown(
|
||||
messageWithFormatting,
|
||||
'Channel::Sms',
|
||||
false
|
||||
);
|
||||
expect(result).toBe('Bold\n\ncode\n\nNormal text');
|
||||
expect(result).toContain('\n\n');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).not.toContain('`');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -896,6 +928,22 @@ describe('stripUnsupportedFormatting', () => {
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves autolinks when schema supports links', () => {
|
||||
const content = 'Check out <https://cegrafic.com/catalogo/>';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves various URI scheme autolinks', () => {
|
||||
const content =
|
||||
'Email <mailto:user@example.com> or call <tel:+1234567890>';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves email autolinks', () => {
|
||||
const content = 'Contact us at <support@chatwoot.com>';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
});
|
||||
|
||||
it('preserves lists when schema supports them', () => {
|
||||
const content = '- item 1\n- item 2\n1. first\n2. second';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
@ -967,6 +1015,38 @@ describe('stripUnsupportedFormatting', () => {
|
||||
).toBe('Check this link');
|
||||
});
|
||||
|
||||
it('converts autolinks to plain URLs when schema does not support links', () => {
|
||||
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
|
||||
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles multiple autolinks in content', () => {
|
||||
const content = 'Check <https://example.com> and <https://test.com>';
|
||||
const expected = 'Check https://example.com and https://test.com';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
|
||||
it('converts URI scheme autolinks to plain text', () => {
|
||||
const content =
|
||||
'Email <mailto:support@example.com> or call <tel:+1234567890>';
|
||||
const expected =
|
||||
'Email mailto:support@example.com or call tel:+1234567890';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
|
||||
it('converts email autolinks to plain text', () => {
|
||||
const content = 'Reach us at <admin@chatwoot.com> for help';
|
||||
const expected = 'Reach us at admin@chatwoot.com for help';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
|
||||
it('handles mixed autolink types', () => {
|
||||
const content = 'Visit <https://example.com> or email <info@example.com>';
|
||||
const expected = 'Visit https://example.com or email info@example.com';
|
||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||
});
|
||||
|
||||
it('strips bullet list markers', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
"CREATED_AT": "Created at",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} found for query '{query}'",
|
||||
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||
"PLACEHOLDER_KEYBINDING": "/to focus",
|
||||
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "from",
|
||||
"EMAIL": "email",
|
||||
"EMAIL_SUBJECT": "subject"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"EMAIL_SUBJECT": "Subject",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_60_DAYS": "Last 60 days",
|
||||
"LAST_90_DAYS": "Last 90 days",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "and",
|
||||
"APPLY": "Apply",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Clear filter"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "Sender",
|
||||
"IN": "Inbox",
|
||||
"AGENTS": "Agents",
|
||||
"CONTACTS": "Contacts",
|
||||
"INBOXES": "Inboxes",
|
||||
"NO_AGENTS": "No agents found",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "الاسم",
|
||||
"DOMAIN": "النطاق",
|
||||
"CREATED_AT": "تم إنشاؤها في"
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "النص"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "جهات الاتصال",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "رسالة",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "اللغة",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "العودة للخلف"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "الكل",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "جهات الاتصال",
|
||||
"CONVERSATIONS": "المحادثات",
|
||||
"MESSAGES": "الرسائل",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "جار التحميل",
|
||||
"EMPTY_STATE": "لم يتم العثور على {item} للطلب '{query}'",
|
||||
"EMPTY_STATE_FULL": "لم يتم العثور على نتائج للطلب '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ للتركيز",
|
||||
"PLACEHOLDER_KEYBINDING": "/للتركيز",
|
||||
"INPUT_PLACEHOLDER": "أكتب 3 أحرف أو أكثر للبحث",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "البحث عن طريق معرف المحادثة أو البريد الإلكتروني أو رقم الهاتف أو الرسائل للحصول على نتائج بحث أفضل. ",
|
||||
"BOT_LABEL": "رد آلي",
|
||||
"READ_MORE": "اقرأ المزيد",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "كتب:",
|
||||
"FROM": "من",
|
||||
"EMAIL": "البريد الإلكتروني",
|
||||
"EMAIL_SUBJECT": "الموضوع"
|
||||
"EMAIL_SUBJECT": "الموضوع",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "آخر 7 أيام",
|
||||
"LAST_30_DAYS": "آخر 30 يوماً",
|
||||
"LAST_60_DAYS": "آخر 60 يوماً",
|
||||
"LAST_90_DAYS": "آخر 90 يوماً",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "و",
|
||||
"APPLY": "تطبيق",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Clear filter"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "المرسل",
|
||||
"IN": "صندوق الوارد",
|
||||
"AGENTS": "الوكلاء",
|
||||
"CONTACTS": "جهات الاتصال",
|
||||
"INBOXES": "قنوات التواصل",
|
||||
"NO_AGENTS": "لم يتم العثور على وكلاء",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
"CREATED_AT": "Created at",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} found for query '{query}'",
|
||||
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||
"PLACEHOLDER_KEYBINDING": "/to focus",
|
||||
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "from",
|
||||
"EMAIL": "email",
|
||||
"EMAIL_SUBJECT": "subject"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"EMAIL_SUBJECT": "Subject",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_60_DAYS": "Last 60 days",
|
||||
"LAST_90_DAYS": "Last 90 days",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "and",
|
||||
"APPLY": "Apply",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Clear filter"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "Sender",
|
||||
"IN": "Inbox",
|
||||
"AGENTS": "Agents",
|
||||
"CONTACTS": "Contacts",
|
||||
"INBOXES": "Inboxes",
|
||||
"NO_AGENTS": "No agents found",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Име",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Създаден в"
|
||||
"CREATED_AT": "Създаден в",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Контакт",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "Съобщение",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "Всички",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Контакти",
|
||||
"CONVERSATIONS": "Разговори",
|
||||
"MESSAGES": "Messages",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} found for query '{query}'",
|
||||
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||
"PLACEHOLDER_KEYBINDING": "/to focus",
|
||||
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
|
||||
"BOT_LABEL": "Бот",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "от",
|
||||
"EMAIL": "имейл",
|
||||
"EMAIL_SUBJECT": "тема"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"EMAIL_SUBJECT": "Subject",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_60_DAYS": "Last 60 days",
|
||||
"LAST_90_DAYS": "Last 90 days",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "and",
|
||||
"APPLY": "Apply",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Clear filter"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "Sender",
|
||||
"IN": "Входяща кутия",
|
||||
"AGENTS": "Агенти",
|
||||
"CONTACTS": "Контакти",
|
||||
"INBOXES": "Inboxes",
|
||||
"NO_AGENTS": "Няма намерени агенти",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
"CREATED_AT": "Created at",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} found for query '{query}'",
|
||||
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||
"PLACEHOLDER_KEYBINDING": "/to focus",
|
||||
"INPUT_PLACEHOLDER": "Type 3 or more characters to search",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "from",
|
||||
"EMAIL": "email",
|
||||
"EMAIL_SUBJECT": "subject"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"EMAIL_SUBJECT": "Subject",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_60_DAYS": "Last 60 days",
|
||||
"LAST_90_DAYS": "Last 90 days",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "and",
|
||||
"APPLY": "Apply",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Clear filter"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "Sender",
|
||||
"IN": "Inbox",
|
||||
"AGENTS": "Agents",
|
||||
"CONTACTS": "Contacts",
|
||||
"INBOXES": "Inboxes",
|
||||
"NO_AGENTS": "No agents found",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Nom",
|
||||
"DOMAIN": "Domini",
|
||||
"CREATED_AT": "Creat per"
|
||||
"CREATED_AT": "Creat per",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Llista"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contacte",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "Missatge",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Idioma",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Torna"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "Totes",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contactes",
|
||||
"CONVERSATIONS": "Converses",
|
||||
"MESSAGES": "Missatges",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Carregant",
|
||||
"EMPTY_STATE": "No s'ha trobat cap {item} per a la consulta '{query}'",
|
||||
"EMPTY_STATE_FULL": "No s'han trobat resultats per a la consulta '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ centrar",
|
||||
"PLACEHOLDER_KEYBINDING": "/centrar",
|
||||
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Esborrar tot",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Llegir més",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "va escriure:",
|
||||
"FROM": "des de",
|
||||
"EMAIL": "correu electrònic",
|
||||
"EMAIL_SUBJECT": "assumpte"
|
||||
"FROM": "Des de",
|
||||
"EMAIL": "Correu electrònic",
|
||||
"EMAIL_SUBJECT": "Assumpte",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "Últims 7 dies",
|
||||
"LAST_30_DAYS": "Últims 30 dies",
|
||||
"LAST_60_DAYS": "Últims 60 dies",
|
||||
"LAST_90_DAYS": "Últims 90 dies",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "and",
|
||||
"APPLY": "Aplica",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Esborra els filtres"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "Remitent",
|
||||
"IN": "Safata d'entrada",
|
||||
"AGENTS": "Agents",
|
||||
"CONTACTS": "Contactes",
|
||||
"INBOXES": "Safates d'entrada",
|
||||
"NO_AGENTS": "No s'han trobat agents",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Název",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Vytvořeno"
|
||||
"CREATED_AT": "Vytvořeno",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
@ -808,6 +808,35 @@
|
||||
"LABEL": "Zpráva",
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "Language",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
},
|
||||
"TEMPLATE_STATUS": {
|
||||
"APPROVED": "Approved by WhatsApp",
|
||||
"PENDING": "Pending WhatsApp approval",
|
||||
"REJECTED": "Meta rejected the template",
|
||||
"DEFAULT": "Needs WhatsApp approval",
|
||||
"NOT_FOUND": "The template does not exist in the Meta platform."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
},
|
||||
"TEMPLATE_UPDATE_DIALOG": {
|
||||
"TITLE": "Edit survey details",
|
||||
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "Vše",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Kontakty",
|
||||
"CONVERSATIONS": "Konverzace",
|
||||
"MESSAGES": "Zprávy",
|
||||
@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} found for query '{query}'",
|
||||
"EMPTY_STATE_FULL": "No results found for query '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ to focus",
|
||||
"PLACEHOLDER_KEYBINDING": "/to focus",
|
||||
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "od",
|
||||
"EMAIL": "e-mailová adresa",
|
||||
"EMAIL_SUBJECT": "předmět"
|
||||
"FROM": "Od",
|
||||
"EMAIL": "E-mailová adresa",
|
||||
"EMAIL_SUBJECT": "Předmět",
|
||||
"PRIVATE": "Private note",
|
||||
"TRANSCRIPT": "Transcript",
|
||||
"CREATED_AT": "created {time}",
|
||||
"UPDATED_AT": "updated {time}",
|
||||
"SORT_BY": {
|
||||
"RELEVANCE": "Relevance"
|
||||
},
|
||||
"DATE_RANGE": {
|
||||
"LAST_7_DAYS": "Posledních 7 dní",
|
||||
"LAST_30_DAYS": "Posledních 30 dní",
|
||||
"LAST_60_DAYS": "Posledních 60 dní",
|
||||
"LAST_90_DAYS": "Posledních 90 dní",
|
||||
"CUSTOM_RANGE": "Custom range:",
|
||||
"CREATED_BETWEEN": "Created between",
|
||||
"AND": "and",
|
||||
"APPLY": "Použít",
|
||||
"BEFORE_DATE": "Before {date}",
|
||||
"AFTER_DATE": "After {date}",
|
||||
"TIME_RANGE": "Filter by time",
|
||||
"CLEAR_FILTER": "Clear filter"
|
||||
},
|
||||
"FILTERS": {
|
||||
"FILTER_MESSAGE": "Filter messages by:",
|
||||
"FROM": "Sender",
|
||||
"IN": "Inbox",
|
||||
"AGENTS": "Agenti",
|
||||
"CONTACTS": "Kontakty",
|
||||
"INBOXES": "Schránky",
|
||||
"NO_AGENTS": "Nenalezeni žádní agenti",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Navn",
|
||||
"DOMAIN": "Domæne",
|
||||
"CREATED_AT": "Oprettet den"
|
||||
"CREATED_AT": "Oprettet den",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Tekst"
|
||||
}
|
||||
},
|
||||
|
||||
@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Kontakt",
|
||||
"COPILOT": "Copilot"
|
||||
},
|
||||
"VOICE_WIDGET": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
"OUTGOING_CALL": "Outgoing call",
|
||||
"CALL_IN_PROGRESS": "Call in progress",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
|
||||
"REJECT_CALL": "Reject",
|
||||
"JOIN_CALL": "Join call",
|
||||
"END_CALL": "End call"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user