Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bae23defd | ||
|
|
4af3e830fc | ||
|
|
b974993886 | ||
|
|
4b849cdd11 | ||
|
|
310590cae3 | ||
|
|
251e9980fd | ||
|
|
2b50909d9b | ||
|
|
290dd3abf5 | ||
|
|
a9123e7d66 | ||
|
|
9967101b48 | ||
|
|
654fcd43f2 | ||
|
|
284977687c | ||
|
|
e585c84778 | ||
|
|
e096804803 | ||
|
|
456a4e0206 | ||
|
|
38f11b010c | ||
|
|
18dc77aa56 | ||
|
|
8aad8ad38e | ||
|
|
098f7a77b6 | ||
|
|
9c22d791c4 | ||
|
|
4d344a47dc | ||
|
|
38dbda9378 | ||
|
|
a4c3d3d8c0 | ||
|
|
688218de0a | ||
|
|
a8d53a6df4 | ||
|
|
2a90652f05 | ||
|
|
270f3c6a80 | ||
|
|
ad1539c6cf | ||
|
|
349f55b558 | ||
|
|
ef91b8bb42 | ||
|
|
de4c837885 | ||
|
|
a62beffeef | ||
|
|
b8f6fe5bb7 | ||
|
|
b866886b55 | ||
|
|
b88236e86e | ||
|
|
11ee741716 | ||
|
|
ac93290c9a | ||
|
|
03719cede0 | ||
|
|
a5c50354fc | ||
|
|
a452ce9e84 | ||
|
|
28bf9fa5f9 | ||
|
|
73a90f2841 | ||
|
|
a90ffe6264 | ||
|
|
4041277a35 | ||
|
|
1b0974b407 | ||
|
|
412b72db7c | ||
|
|
8aa49f69d2 | ||
|
|
550b408656 | ||
|
|
b103747584 | ||
|
|
d6d38cdd7d | ||
|
|
e4f9355352 | ||
|
|
199dcd382e | ||
|
|
fac4675dc5 | ||
|
|
c6f82783ba | ||
|
|
9b3f0029a4 | ||
|
|
6e46be36c8 | ||
|
|
87f5af4caa | ||
|
|
dbe35252bc | ||
|
|
de8aa48b83 | ||
|
|
43977a1927 | ||
|
|
a9cabad529 | ||
|
|
fdc326094a | ||
|
|
9a9398b386 | ||
|
|
dc0e5eb465 | ||
|
|
79218be5c4 | ||
|
|
8d9dd99012 | ||
|
|
9f376c43b5 | ||
|
|
824164852c | ||
|
|
8ea93ec73d | ||
|
|
28f58b3694 |
@@ -1,3 +1,4 @@
|
||||
---
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
|
||||
@@ -93,8 +93,8 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/7.19.0/openapi-generator-cli-7.19.0.jar > ~/tmp/openapi-generator-cli-7.19.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-7.19.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# Bundle audit
|
||||
- run:
|
||||
|
||||
@@ -268,6 +268,7 @@ group :development, :test do
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'skooma'
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+13
-2
@@ -166,7 +166,7 @@ GEM
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt (3.1.22)
|
||||
benchmark (0.4.1)
|
||||
bigdecimal (3.2.2)
|
||||
bindex (0.8.1)
|
||||
@@ -465,7 +465,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.18.1)
|
||||
json (2.19.2)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -473,6 +473,12 @@ GEM
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri_template (~> 0.7)
|
||||
json_skooma (0.2.5)
|
||||
bigdecimal
|
||||
hana (~> 1.3)
|
||||
regexp_parser (~> 2.0)
|
||||
uri-idna (~> 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
judoscale-rails (1.8.2)
|
||||
judoscale-ruby (= 1.8.2)
|
||||
railties
|
||||
@@ -910,6 +916,9 @@ GEM
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
skooma (0.3.7)
|
||||
json_skooma (~> 0.2.5)
|
||||
zeitwerk (~> 2.6)
|
||||
slack-ruby-client (2.7.0)
|
||||
faraday (>= 2.0.1)
|
||||
faraday-mashify
|
||||
@@ -970,6 +979,7 @@ GEM
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.1.1)
|
||||
uri-idna (0.3.1)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1143,6 +1153,7 @@ DEPENDENCIES
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
skooma
|
||||
slack-ruby-client (~> 2.7.0)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.11.2
|
||||
4.12.0
|
||||
|
||||
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
@contact.save! if @contact.changed?
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class V2::Reports::BaseSummaryBuilder
|
||||
include DateRangeHelper
|
||||
include V2::Reports::Concerns::RollupConditions
|
||||
|
||||
def build
|
||||
load_data
|
||||
@@ -9,15 +8,9 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
private
|
||||
|
||||
# Summary builders fetch all metrics in a single query, so the per-metric
|
||||
# check from RollupConditions does not apply.
|
||||
def metric_covered?
|
||||
true
|
||||
end
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
use_rollup? ? load_rollup_data : load_reporting_events_data
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
@@ -41,46 +34,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
def load_rollup_data
|
||||
dimension_type = dimension_type_to_rollup
|
||||
value_col = rollup_value_column
|
||||
|
||||
# Fetch rollup data grouped by dimension_id
|
||||
rollup_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
dimension_type: dimension_type,
|
||||
date: rollup_date_range
|
||||
).group(:dimension_id).select(
|
||||
'dimension_id',
|
||||
'SUM(CASE WHEN metric = \'resolutions_count\' THEN count ELSE 0 END) as resolved_count',
|
||||
"SUM(CASE WHEN metric = 'resolution_time' THEN count ELSE 0 END) as resolution_count",
|
||||
"SUM(CASE WHEN metric = 'resolution_time' THEN #{value_col} ELSE 0 END) as resolution_sum_value",
|
||||
"SUM(CASE WHEN metric = 'first_response' THEN count ELSE 0 END) as first_response_count",
|
||||
"SUM(CASE WHEN metric = 'first_response' THEN #{value_col} ELSE 0 END) as first_response_sum_value",
|
||||
"SUM(CASE WHEN metric = 'reply_time' THEN count ELSE 0 END) as reply_count",
|
||||
"SUM(CASE WHEN metric = 'reply_time' THEN #{value_col} ELSE 0 END) as reply_sum_value"
|
||||
)
|
||||
|
||||
# Convert to the expected format
|
||||
results = {}
|
||||
rollup_rows.each do |row|
|
||||
results[row.dimension_id] = row
|
||||
end
|
||||
|
||||
@resolved_count = results.transform_values { |r| r.resolved_count.to_i }
|
||||
@avg_resolution_time = results.transform_values do |r|
|
||||
r.resolution_count.to_i.zero? ? nil : (r.resolution_sum_value.to_f / r.resolution_count.to_i)
|
||||
end
|
||||
@avg_first_response_time = results.transform_values do |r|
|
||||
r.first_response_count.to_i.zero? ? nil : (r.first_response_sum_value.to_f / r.first_response_count.to_i)
|
||||
end
|
||||
@avg_reply_time = results.transform_values do |r|
|
||||
r.reply_count.to_i.zero? ? nil : (r.reply_sum_value.to_f / r.reply_count.to_i)
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
module V2::Reports::Concerns::RollupConditions
|
||||
COVERED_METRICS = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :resolution_time,
|
||||
reply_time: :reply_time,
|
||||
resolutions_count: :resolutions_count,
|
||||
bot_resolutions_count: :bot_resolutions_count,
|
||||
bot_handoffs_count: :bot_handoffs_count
|
||||
}.freeze
|
||||
|
||||
SUPPORTED_DIMENSIONS = %w[account agent inbox].freeze
|
||||
|
||||
def use_rollup?
|
||||
# Condition 0: reporting_timezone must be configured on account
|
||||
return false if account.reporting_timezone.blank?
|
||||
|
||||
# Condition 1: Feature flag must be enabled
|
||||
return false unless account.feature_enabled?('reporting_events_rollup')
|
||||
|
||||
# Condition 2: Metric must be covered by rollup
|
||||
return false unless metric_covered?
|
||||
|
||||
# Condition 3: Not hourly granularity
|
||||
return false if params[:group_by] == 'hour'
|
||||
|
||||
# Condition 4: Dimension must be supported
|
||||
return false unless dimension_supported?
|
||||
|
||||
# Condition 5: Timezone must match
|
||||
timezone_matches_account?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric_covered?
|
||||
return false if params[:metric].blank?
|
||||
|
||||
COVERED_METRICS.key?(params[:metric].to_sym)
|
||||
end
|
||||
|
||||
def dimension_supported?
|
||||
return true if params[:type].blank? # account dimension (no specific ID)
|
||||
|
||||
SUPPORTED_DIMENSIONS.include?(params[:type].to_s)
|
||||
end
|
||||
|
||||
def timezone_matches_account?
|
||||
return false if params[:timezone_offset].blank?
|
||||
|
||||
offset_in_seconds = params[:timezone_offset].to_f * 3600
|
||||
account_zone = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
account_zone&.now&.utc_offset == offset_in_seconds
|
||||
end
|
||||
|
||||
def metric_to_rollup_metric(metric)
|
||||
COVERED_METRICS[metric.to_sym]
|
||||
end
|
||||
|
||||
def rollup_value_column
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :sum_value_business_hours : :sum_value
|
||||
end
|
||||
|
||||
def rollup_date_range
|
||||
tz = ActiveSupport::TimeZone[account.reporting_timezone]
|
||||
start_date = range.first.in_time_zone(tz).to_date
|
||||
# range is exclusive (since...until), so subtract a second to avoid
|
||||
# including the until boundary day when it falls exactly at midnight.
|
||||
end_date = (range.last - 1.second).in_time_zone(tz).to_date
|
||||
start_date..end_date
|
||||
end
|
||||
|
||||
def dimension_type_to_rollup
|
||||
{ 'account' => :account, 'agent' => :agent, 'inbox' => :inbox }[params[:type].to_s]
|
||||
end
|
||||
end
|
||||
@@ -11,6 +11,11 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
use_rollup? ? rollup_timeseries : raw_timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def raw_timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
@@ -22,98 +12,11 @@ class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::B
|
||||
end
|
||||
end
|
||||
|
||||
def rollup_timeseries
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
|
||||
rollup_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
)
|
||||
|
||||
group_and_aggregate_rollup(rollup_rows)
|
||||
end
|
||||
|
||||
def raw_aggregate_value
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
def rollup_aggregate_value
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
|
||||
value_col = rollup_value_column
|
||||
|
||||
result = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
).pick(Arel.sql("SUM(count), SUM(#{value_col})"))
|
||||
|
||||
return nil if result.blank? || result[0].to_i.zero?
|
||||
|
||||
result[1].to_f / result[0].to_i
|
||||
end
|
||||
|
||||
def dimension_id_for_rollup
|
||||
params[:type].to_s == 'account' ? account.id : scope.id
|
||||
end
|
||||
|
||||
def group_and_aggregate_rollup(rollup_rows)
|
||||
grouped_data = aggregate_rollup_rows(rollup_rows, all_periods_in_range.index_with { { count: 0, sum_value: 0.0 } })
|
||||
|
||||
results = grouped_data.map do |date_key, data|
|
||||
value = data[:count].zero? ? 0 : data[:sum_value] / data[:count]
|
||||
{ value: value, timestamp: date_key.in_time_zone(timezone).to_i, count: data[:count] }
|
||||
end
|
||||
results.sort_by { |h| h[:timestamp] }
|
||||
end
|
||||
|
||||
def aggregate_rollup_rows(rollup_rows, grouped_data = {})
|
||||
value_col = rollup_value_column
|
||||
rollup_rows.each_with_object(grouped_data) do |row, all_grouped_data|
|
||||
date_key = date_key_for_group(row.date)
|
||||
all_grouped_data[date_key] ||= { count: 0, sum_value: 0.0 }
|
||||
all_grouped_data[date_key][:count] += row.count
|
||||
all_grouped_data[date_key][:sum_value] += row.public_send(value_col)
|
||||
end
|
||||
end
|
||||
|
||||
def date_key_for_group(date)
|
||||
case group_by
|
||||
when 'week' then date.beginning_of_week(:sunday)
|
||||
when 'month' then date.beginning_of_month
|
||||
when 'year' then date.beginning_of_year
|
||||
else date
|
||||
end
|
||||
end
|
||||
|
||||
def all_periods_in_range
|
||||
date_range = rollup_date_range
|
||||
dates = []
|
||||
current = date_key_for_group(date_range.first)
|
||||
while current <= date_range.last
|
||||
dates << current
|
||||
current = advance_period(current)
|
||||
end
|
||||
dates
|
||||
end
|
||||
|
||||
def advance_period(date)
|
||||
case group_by
|
||||
when 'week' then date + 1.week
|
||||
when 'month' then date + 1.month
|
||||
when 'year' then date + 1.year
|
||||
else date + 1.day
|
||||
end
|
||||
end
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
include V2::Reports::Concerns::RollupConditions
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
use_rollup? ? rollup_timeseries : raw_timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
use_rollup? ? rollup_aggregate_value : raw_aggregate_value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def raw_timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
@@ -21,92 +11,11 @@ class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::Bas
|
||||
end
|
||||
end
|
||||
|
||||
def rollup_timeseries
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
return [] if metric.blank?
|
||||
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
|
||||
rollup_rows = ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
)
|
||||
|
||||
group_and_aggregate_rollup_counts(rollup_rows)
|
||||
end
|
||||
|
||||
def raw_aggregate_value
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
def rollup_aggregate_value
|
||||
metric = metric_to_rollup_metric(params[:metric])
|
||||
return 0 if metric.blank?
|
||||
|
||||
dimension_type = dimension_type_to_rollup
|
||||
dimension_id = dimension_id_for_rollup
|
||||
|
||||
ReportingEventsRollup.where(
|
||||
account_id: account.id,
|
||||
metric: metric,
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: dimension_id,
|
||||
date: rollup_date_range
|
||||
).sum(:count).to_i
|
||||
end
|
||||
|
||||
def dimension_id_for_rollup
|
||||
params[:type].to_s == 'account' ? account.id : scope.id
|
||||
end
|
||||
|
||||
def group_and_aggregate_rollup_counts(rollup_rows)
|
||||
# Pre-fill all periods with zero to match raw path's default_value: 0 behavior
|
||||
grouped_data = all_periods_in_range.index_with { 0 }
|
||||
|
||||
rollup_rows.each do |row|
|
||||
date_key = date_key_for_period(row.date)
|
||||
grouped_data[date_key] ||= 0
|
||||
grouped_data[date_key] += row.count
|
||||
end
|
||||
|
||||
results = grouped_data.map do |date_key, count|
|
||||
{ value: count, timestamp: date_key.in_time_zone(timezone).to_i }
|
||||
end
|
||||
results.sort_by { |h| h[:timestamp] }
|
||||
end
|
||||
|
||||
def date_key_for_period(date)
|
||||
case group_by
|
||||
when 'week' then date.beginning_of_week(:sunday)
|
||||
when 'month' then date.beginning_of_month
|
||||
when 'year' then date.beginning_of_year
|
||||
else date
|
||||
end
|
||||
end
|
||||
|
||||
def all_periods_in_range
|
||||
date_range = rollup_date_range
|
||||
dates = []
|
||||
current = date_key_for_period(date_range.first)
|
||||
while current <= date_range.last
|
||||
dates << current
|
||||
current = advance_period(current)
|
||||
end
|
||||
dates
|
||||
end
|
||||
|
||||
def advance_period(date)
|
||||
case group_by
|
||||
when 'week' then date + 1.week
|
||||
when 'month' then date + 1.month
|
||||
when 'year' then date + 1.year
|
||||
else date + 1.day
|
||||
end
|
||||
end
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -201,7 +201,9 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_contact
|
||||
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
|
||||
contact_scope = Current.account.contacts
|
||||
contact_scope = contact_scope.includes(contact_inboxes: [:inbox]) if @include_contact_inboxes
|
||||
@contact = contact_scope.find(params[:id])
|
||||
end
|
||||
|
||||
def process_avatar_from_url
|
||||
|
||||
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
return unless @hook&.access_token
|
||||
|
||||
begin
|
||||
linear_client = Linear.new(@hook.access_token)
|
||||
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
|
||||
linear_client.revoke_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||
|
||||
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
end
|
||||
|
||||
def label
|
||||
@@ -36,17 +36,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
until: permitted_params[:until],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(permitted_params[:business_hours])
|
||||
}
|
||||
@builder_params[:timezone_offset] = permitted_params[:timezone_offset] if permitted_params[:timezone_offset].present?
|
||||
end
|
||||
|
||||
def render_report_with(builder_class, type: nil)
|
||||
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours, :timezone_offset)
|
||||
params.permit(:since, :until, :business_hours)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
|
||||
@@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController
|
||||
include Linear::IntegrationHelper
|
||||
|
||||
def show
|
||||
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/linear/callback"
|
||||
@@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController
|
||||
handle_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to linear_redirect_uri
|
||||
redirect_to safe_linear_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
@@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def handle_response
|
||||
hook = account.hooks.new(
|
||||
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
|
||||
|
||||
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
|
||||
hook.assign_attributes(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'linear',
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
settings: merged_integration_settings(hook.settings)
|
||||
)
|
||||
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
|
||||
hook.save!
|
||||
redirect_to linear_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to linear_redirect_uri
|
||||
redirect_to safe_linear_redirect_uri
|
||||
end
|
||||
|
||||
def account
|
||||
@@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def account_id
|
||||
return unless params[:state]
|
||||
return @account_id if instance_variable_defined?(:@account_id)
|
||||
|
||||
verify_linear_token(params[:state])
|
||||
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
|
||||
end
|
||||
|
||||
def linear_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
||||
end
|
||||
|
||||
def safe_linear_redirect_uri
|
||||
return base_url if account_id.blank?
|
||||
|
||||
linear_redirect_uri
|
||||
rescue StandardError
|
||||
base_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def integration_settings
|
||||
{
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
expires_on: expires_on,
|
||||
scope: parsed_body['scope'],
|
||||
refresh_token: parsed_body['refresh_token']
|
||||
}.compact
|
||||
end
|
||||
|
||||
def merged_integration_settings(existing_settings)
|
||||
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
|
||||
end
|
||||
|
||||
def expires_on
|
||||
return if parsed_body['expires_in'].blank?
|
||||
|
||||
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@search_query = list_params[:query]
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
|
||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||
@@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
|
||||
permitted[:query] = permitted[:query].to_s.strip.presence
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
if @web_widget.allowed_domains.blank?
|
||||
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
|
||||
response.headers.delete('X-Frame-Options')
|
||||
else
|
||||
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
||||
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
||||
end
|
||||
end
|
||||
|
||||
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
|
||||
# which cannot match any domain in frame-ancestors. When the per-inbox flag
|
||||
# is enabled, skip frame-ancestors for these requests.
|
||||
def embedded_from_non_web_origin?
|
||||
return false unless @web_widget.allow_mobile_webview?
|
||||
|
||||
origin = request.headers['Origin']
|
||||
origin.blank? || origin == 'null' || origin&.start_with?('file://')
|
||||
end
|
||||
end
|
||||
|
||||
WidgetsController.prepend_mod_with('WidgetsController')
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/* global axios */
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
const getTimeOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||
|
||||
class SummaryReportsAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('summary_reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
@@ -14,7 +12,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -25,7 +22,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,7 +32,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -47,7 +42,6 @@ class SummaryReportsAPI extends ApiClient {
|
||||
since,
|
||||
until,
|
||||
business_hours: businessHours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
+18
-2
@@ -1,8 +1,22 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
{
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -19,6 +33,8 @@ const locales = [
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -17,6 +17,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isDraft: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -37,11 +41,28 @@ const { t } = useI18n();
|
||||
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
||||
|
||||
const localeMenuLabels = computed(() => ({
|
||||
'change-default': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
||||
),
|
||||
'move-to-draft': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
||||
),
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
buildLocaleMenuItems({
|
||||
isDefault: props.isDefault,
|
||||
isDraft: props.isDraft,
|
||||
}).map(item => ({
|
||||
...item,
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
label: localeMenuLabels.value[item.action],
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -56,7 +77,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ locale }} ({{ localeCode }})
|
||||
{{ localeLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
@@ -64,6 +85,12 @@ const handleAction = ({ action, value }) => {
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isDraft"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -86,6 +113,7 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="localeMenuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
|
||||
+48
-2
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -24,12 +24,20 @@ const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
const localeStatus = ref('published');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const draftedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales
|
||||
.filter(locale => locale.draft)
|
||||
.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
@@ -41,17 +49,44 @@ const locales = computed(() => {
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{
|
||||
value: 'published',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
||||
},
|
||||
{
|
||||
value: 'draft',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
localeStatus.value = 'published';
|
||||
};
|
||||
|
||||
watch(localeStatus, value => {
|
||||
if (!value) {
|
||||
localeStatus.value = 'published';
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
const updatedDraftLocales =
|
||||
localeStatus.value === 'draft'
|
||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
||||
: draftedLocales.value;
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: {
|
||||
allowed_locales: updatedLocales,
|
||||
draft_locales: updatedDraftLocales,
|
||||
default_locale: props.portal?.meta?.default_locale,
|
||||
},
|
||||
});
|
||||
@@ -62,7 +97,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
selectedLocale.value = '';
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
@@ -87,6 +122,7 @@ defineExpose({ dialogRef });
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@close="resetForm"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -98,6 +134,16 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="localeStatus"
|
||||
:options="statusOptions"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,6 +29,7 @@ const isLocaleDefault = code => {
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
@@ -39,6 +40,7 @@ const updatePortalLocales = async ({
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
draft_locales: newDraftLocales,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,8 +55,12 @@ const updatePortalLocales = async ({
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const newDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
@@ -81,11 +87,15 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
const updatedDraftLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
newDraftLocales: updatedDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
@@ -98,9 +108,46 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const currentDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
const newDraftLocales = shouldDraft
|
||||
? [...new Set([...currentDraftLocales, localeCode])]
|
||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: props.portal.meta.default_locale,
|
||||
messageKey,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: true,
|
||||
messageKey: 'DRAFT_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const publishLocale = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: false,
|
||||
messageKey: 'PUBLISH_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'move-to-draft') {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -114,6 +161,7 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
|
||||
+12
@@ -4,37 +4,49 @@ import LocalesPage from './LocalesPage.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
code: 'en_GB',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
code: 'ms',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
code: 'ml',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
code: 'hi',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({ type: Boolean, default: false });
|
||||
@@ -28,7 +32,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
|
||||
@@ -18,10 +18,18 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -62,6 +70,7 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -71,6 +80,12 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,7 +119,7 @@ const sendMessage = async () => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
+7
@@ -27,6 +27,7 @@ const initialState = {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
citations: false,
|
||||
contactAttributes: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,6 +60,7 @@ const updateStateFromAssistant = assistant => {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
citations: config.feature_citation || false,
|
||||
contactAttributes: config.feature_contact_attributes || false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,6 +81,7 @@ const handleBasicInfoUpdate = async () => {
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
feature_citation: state.features.citations,
|
||||
feature_contact_attributes: state.features.contactAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,6 +141,10 @@ watch(
|
||||
<input v-model="state.features.citations" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.contactAttributes" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -43,7 +49,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -67,6 +67,9 @@ const isSent = computed(() => {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// API inbox messages use real sent/delivered/read status values from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (isALineChannel.value) return true;
|
||||
|
||||
@@ -86,8 +89,10 @@ const isDelivered = computed(() => {
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
// API inbox messages use real delivered status from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
// All messages marked as delivered for the web widget inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value) {
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
if (isALineChannel.value) {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VoiceCallBubble from './VoiceCall.vue';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
let messageContext;
|
||||
let joinCallMock;
|
||||
let endCallMock;
|
||||
let conversationsById;
|
||||
let activeCall;
|
||||
let hasActiveCall;
|
||||
let isJoining;
|
||||
|
||||
vi.mock('../provider.js', () => ({
|
||||
useMessageContext: () => messageContext,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useCallSession', () => ({
|
||||
useCallSession: () => ({
|
||||
activeCall,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
joinCall: joinCallMock,
|
||||
endCall: endCallMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vuex', () => ({
|
||||
useStore: () => ({
|
||||
getters: {
|
||||
getConversationById: id => conversationsById[id] || null,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key, params = {}) => {
|
||||
const translations = {
|
||||
'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS': 'Call in progress',
|
||||
'CONVERSATION.VOICE_CALL.AGENT_ANSWERED': `${params.agentName} answered`,
|
||||
'CONVERSATION.VOICE_CALL.YOU_ANSWERED': 'You answered',
|
||||
'CONVERSATION.VOICE_CALL.THEY_ANSWERED': 'They answered',
|
||||
'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET': 'Not answered yet',
|
||||
'CONVERSATION.VOICE_CALL.CALL_ENDED': 'Call ended',
|
||||
'CONVERSATION.VOICE_CALL.NO_ANSWER': 'No answer',
|
||||
'CONVERSATION.VOICE_CALL.JOIN_CALL': 'Join call',
|
||||
'CONVERSATION.VOICE_CALL.REJOIN_CALL': 'Rejoin call',
|
||||
};
|
||||
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mountComponent = () =>
|
||||
mount(VoiceCallBubble, {
|
||||
global: {
|
||||
stubs: {
|
||||
BaseBubble: {
|
||||
template: '<div><slot /></div>',
|
||||
},
|
||||
Icon: true,
|
||||
AudioChip: {
|
||||
props: ['attachment'],
|
||||
template:
|
||||
'<div data-test-id="audio-chip">{{ attachment.dataUrl }}</div>',
|
||||
},
|
||||
},
|
||||
mocks: {
|
||||
$t: key => key,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('VoiceCall.vue', () => {
|
||||
beforeEach(() => {
|
||||
joinCallMock = vi.fn().mockResolvedValue({ conferenceSid: 'CF123' });
|
||||
endCallMock = vi.fn().mockResolvedValue();
|
||||
conversationsById = {
|
||||
12: {
|
||||
id: 12,
|
||||
inbox_id: 3,
|
||||
meta: {
|
||||
assignee: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
activeCall = ref(null);
|
||||
hasActiveCall = ref(false);
|
||||
isJoining = ref(false);
|
||||
messageContext = {
|
||||
contentAttributes: ref({
|
||||
data: {
|
||||
status: VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
call_sid: 'CALL123',
|
||||
meta: {},
|
||||
},
|
||||
}),
|
||||
attachments: ref([]),
|
||||
conversationId: ref(12),
|
||||
currentUserId: ref(1),
|
||||
inboxId: ref(3),
|
||||
messageType: ref(MESSAGE_TYPES.INCOMING),
|
||||
};
|
||||
});
|
||||
|
||||
it('shows the answering agent name when another agent answers the call', () => {
|
||||
messageContext.contentAttributes.value.data.meta.joinedBy = {
|
||||
id: 2,
|
||||
name: 'Ben',
|
||||
};
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain('Ben answered');
|
||||
});
|
||||
|
||||
it('shows "You answered" when the current user answered the call', () => {
|
||||
messageContext.contentAttributes.value.data.meta.joinedBy = {
|
||||
id: 1,
|
||||
name: 'John',
|
||||
};
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain('You answered');
|
||||
});
|
||||
|
||||
it('shows the formatted duration when a call has ended', () => {
|
||||
messageContext.contentAttributes.value.data.status =
|
||||
VOICE_CALL_STATUS.COMPLETED;
|
||||
messageContext.contentAttributes.value.data.meta.duration = 133;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.text()).toContain('Call ended');
|
||||
expect(wrapper.text()).toContain('02:13');
|
||||
});
|
||||
|
||||
it('renders the recording attachment on the voice call message', () => {
|
||||
messageContext.attachments.value = [
|
||||
{
|
||||
id: 1,
|
||||
fileType: 'audio',
|
||||
dataUrl: 'https://example.com/recording.mp3',
|
||||
},
|
||||
];
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
expect(wrapper.find('[data-test-id="audio-chip"]').exists()).toBe(true);
|
||||
expect(wrapper.text()).toContain('https://example.com/recording.mp3');
|
||||
});
|
||||
|
||||
it('shows a rejoin action for an in-progress call when not already connected', async () => {
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.find('[data-test-id="voice-call-join"]').trigger('click');
|
||||
|
||||
expect(joinCallMock).toHaveBeenCalledWith({
|
||||
conversationId: 12,
|
||||
inboxId: 3,
|
||||
callSid: 'CALL123',
|
||||
});
|
||||
expect(wrapper.text()).toContain('Rejoin call');
|
||||
});
|
||||
|
||||
it('joins the call after refresh when the payload is camel-cased', async () => {
|
||||
messageContext.contentAttributes.value.data = {
|
||||
status: VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
callSid: 'CALL456',
|
||||
meta: {},
|
||||
};
|
||||
|
||||
const wrapper = mountComponent();
|
||||
|
||||
await wrapper.find('[data-test-id="voice-call-join"]').trigger('click');
|
||||
|
||||
expect(joinCallMock).toHaveBeenCalledWith({
|
||||
conversationId: 12,
|
||||
inboxId: 3,
|
||||
callSid: 'CALL456',
|
||||
});
|
||||
expect(wrapper.text()).toContain('Rejoin call');
|
||||
});
|
||||
|
||||
it('hides the rejoin action when the agent is already on the same call', async () => {
|
||||
activeCall.value = {
|
||||
callSid: 'CALL123',
|
||||
conversationId: 12,
|
||||
};
|
||||
hasActiveCall.value = true;
|
||||
|
||||
const wrapper = mountComponent();
|
||||
const joinButton = wrapper.find('[data-test-id="voice-call-join"]');
|
||||
|
||||
await joinButton.trigger('click');
|
||||
|
||||
expect(joinButton.attributes('disabled')).toBeDefined();
|
||||
expect(joinCallMock).not.toHaveBeenCalled();
|
||||
expect(wrapper.text()).not.toContain('Join call');
|
||||
expect(wrapper.text()).not.toContain('Rejoin call');
|
||||
});
|
||||
|
||||
it('does not expose a join action when another agent is assigned', () => {
|
||||
conversationsById[12].meta.assignee = { id: 99, name: 'Ben' };
|
||||
|
||||
const wrapper = mountComponent();
|
||||
const joinButton = wrapper.find('[data-test-id="voice-call-join"]');
|
||||
|
||||
expect(joinButton.attributes('disabled')).toBeDefined();
|
||||
expect(wrapper.text()).not.toContain('Join call');
|
||||
expect(wrapper.text()).not.toContain('Rejoin call');
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,19 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
import {
|
||||
ATTACHMENT_TYPES,
|
||||
MESSAGE_TYPES,
|
||||
VOICE_CALL_STATUS,
|
||||
} from '../constants';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
@@ -30,38 +29,114 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { activeCall, hasActiveCall, isJoining, joinCall, endCall } =
|
||||
useCallSession({
|
||||
manageSessionState: false,
|
||||
});
|
||||
const {
|
||||
attachments,
|
||||
contentAttributes,
|
||||
conversationId,
|
||||
currentUserId,
|
||||
inboxId,
|
||||
messageType,
|
||||
} = useMessageContext();
|
||||
|
||||
const JOINABLE_STATUSES = [
|
||||
VOICE_CALL_STATUS.RINGING,
|
||||
VOICE_CALL_STATUS.IN_PROGRESS,
|
||||
];
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
const callSid = computed(() => {
|
||||
return (data.value?.callSid || data.value?.call_sid || '').toString();
|
||||
});
|
||||
const joinedBy = computed(() => {
|
||||
return data.value?.meta?.joinedBy || data.value?.meta?.joined_by;
|
||||
});
|
||||
const callDuration = computed(() => {
|
||||
return data.value?.meta?.duration;
|
||||
});
|
||||
const recordingAttachment = computed(() => {
|
||||
return attachments.value?.find(
|
||||
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
|
||||
);
|
||||
});
|
||||
const conversation = computed(() => {
|
||||
return store.getters.getConversationById?.(conversationId.value) || null;
|
||||
});
|
||||
const activeConversation = computed(() => {
|
||||
const activeConversationId = activeCall.value?.conversationId;
|
||||
if (!activeConversationId) return null;
|
||||
|
||||
return store.getters.getConversationById?.(activeConversationId) || null;
|
||||
});
|
||||
const assigneeId = computed(() => {
|
||||
return (
|
||||
conversation.value?.meta?.assignee?.id || conversation.value?.assignee_id
|
||||
);
|
||||
});
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
const didCurrentUserAnswer = computed(() => {
|
||||
return joinedBy.value?.id === currentUserId.value;
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
const label = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS');
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? t('CONVERSATION.VOICE_CALL.OUTGOING_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
}
|
||||
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
? t('CONVERSATION.VOICE_CALL.MISSED_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.INCOMING_CALL');
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return (
|
||||
formatDuration(callDuration.value) ||
|
||||
t('CONVERSATION.VOICE_CALL.CALL_ENDED')
|
||||
);
|
||||
}
|
||||
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
|
||||
if (joinedBy.value?.name) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: joinedBy.value.name,
|
||||
});
|
||||
}
|
||||
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
}
|
||||
|
||||
return isFailed.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@@ -70,12 +145,66 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
const resolvedInboxId = computed(() => {
|
||||
return inboxId.value || conversation.value?.inbox_id || null;
|
||||
});
|
||||
const isCurrentActiveCall = computed(() => {
|
||||
return activeCall.value?.callSid === callSid.value;
|
||||
});
|
||||
const canJoinCall = computed(() => {
|
||||
return (
|
||||
JOINABLE_STATUSES.includes(status.value) &&
|
||||
!!conversationId.value &&
|
||||
!!resolvedInboxId.value &&
|
||||
!!callSid.value &&
|
||||
(!assigneeId.value || assigneeId.value === currentUserId.value) &&
|
||||
!isCurrentActiveCall.value
|
||||
);
|
||||
});
|
||||
const joinCallText = computed(() => {
|
||||
return status.value === VOICE_CALL_STATUS.IN_PROGRESS
|
||||
? t('CONVERSATION.VOICE_CALL.REJOIN_CALL')
|
||||
: t('CONVERSATION.VOICE_CALL.JOIN_CALL');
|
||||
});
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (!canJoinCall.value || isJoining.value) return;
|
||||
|
||||
if (hasActiveCall.value) {
|
||||
if (activeCall.value?.callSid === callSid.value) return;
|
||||
|
||||
const activeInboxId = activeConversation.value?.inbox_id;
|
||||
if (activeCall.value?.conversationId && activeInboxId) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeInboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: resolvedInboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-sm">
|
||||
<button
|
||||
class="flex gap-3 items-center p-3 w-full text-left border-none bg-transparent"
|
||||
:class="{
|
||||
'cursor-pointer transition-colors hover:bg-n-alpha-1 active:bg-n-alpha-2':
|
||||
canJoinCall,
|
||||
'cursor-default': !canJoinCall,
|
||||
}"
|
||||
:disabled="!canJoinCall || isJoining"
|
||||
data-test-id="voice-call-join"
|
||||
type="button"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bgColor"
|
||||
@@ -92,12 +221,21 @@ const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
{{ subtext }}
|
||||
</span>
|
||||
<span
|
||||
v-if="canJoinCall"
|
||||
class="mt-1 text-xs font-medium text-n-teal-10"
|
||||
>
|
||||
{{ joinCallText }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div v-if="recordingAttachment" class="px-3 pb-3">
|
||||
<AudioChip class="text-n-slate-12" :attachment="recordingAttachment" />
|
||||
</div>
|
||||
</div>
|
||||
</BaseBubble>
|
||||
|
||||
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -828,6 +828,8 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focusEditorInputField });
|
||||
|
||||
// BUS Event to insert text or markdown into the editor at the
|
||||
// current cursor position.
|
||||
// Components using this
|
||||
|
||||
@@ -78,7 +78,7 @@ const onSend = () => {
|
||||
<div
|
||||
v-else-if="isGeneratingContent"
|
||||
key="loading-state"
|
||||
class="bg-n-iris-5 rounded min-h-16 w-full mb-4 p-4 flex items-start"
|
||||
class="bg-n-iris-5 rounded min-h-[4.75rem] w-full mb-4 p-4 flex items-start"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<CaptainLoader class="text-n-iris-10 size-4" />
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const messageEditor = useTemplateRef('messageEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
@@ -109,6 +110,7 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
messageEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
@@ -162,7 +164,8 @@ export default {
|
||||
this.inReplyTo?.id &&
|
||||
!this.isPrivate &&
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
|
||||
!this.is360DialogWhatsAppChannel
|
||||
!this.is360DialogWhatsAppChannel &&
|
||||
!this.copilot.isActive.value
|
||||
);
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
@@ -506,7 +509,7 @@ export default {
|
||||
);
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
|
||||
// A hacky fix to solve the drag and drop
|
||||
// Is showing on top of new conversation modal drag and drop
|
||||
@@ -521,7 +524,7 @@ export default {
|
||||
unmounted() {
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
||||
emitter.off(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
@@ -1190,6 +1193,15 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onReplyToMessage() {
|
||||
this.fetchAndSetReplyTo();
|
||||
if (this.inReplyTo) {
|
||||
this.$nextTick(() => {
|
||||
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
|
||||
this.messageEditor?.focusEditorInputField(pos);
|
||||
});
|
||||
}
|
||||
},
|
||||
resetReplyToMessage() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
@@ -1312,6 +1324,7 @@ export default {
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
ref="messageEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
@@ -1464,7 +1477,7 @@ export default {
|
||||
}
|
||||
|
||||
.reply-box__top {
|
||||
@apply relative py-0 px-4 -mt-px;
|
||||
@apply relative py-0 px-3 -mt-px;
|
||||
}
|
||||
|
||||
.emoji-dialog {
|
||||
|
||||
@@ -14,7 +14,7 @@ const emit = defineEmits(['dismiss']);
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5 -mx-2"
|
||||
class="reply-editor bg-n-slate-9/10 rounded-md py-1 pl-2 pr-1 text-xs tracking-wide mt-2 flex items-center gap-1.5"
|
||||
>
|
||||
<fluent-icon class="flex-shrink-0 icon" icon="arrow-reply" size="14" />
|
||||
<div class="flex-grow gap-1 mt-px text-xs truncate">
|
||||
@@ -32,6 +32,7 @@ const emit = defineEmits(['dismiss']);
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallSession } from '../useCallSession';
|
||||
|
||||
vi.mock('vue', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
onMounted: vi.fn(),
|
||||
onUnmounted: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
useAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/channel/voice/voiceAPIClient', () => ({
|
||||
default: {
|
||||
joinConference: vi.fn(),
|
||||
leaveConference: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/channel/voice/twilioVoiceClient', () => ({
|
||||
default: {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
initializeDevice: vi.fn(),
|
||||
joinClientCall: vi.fn(),
|
||||
endClientCall: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn(key => key),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useCallSession', () => {
|
||||
let consoleErrorSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows the backend conflict message when another agent already claimed the call', async () => {
|
||||
TwilioVoiceClient.initializeDevice.mockResolvedValue({ device: true });
|
||||
VoiceAPI.joinConference.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: 'Jane Agent is already handling the call.',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { joinCall } = useCallSession();
|
||||
const result = await joinCall({
|
||||
conversationId: 42,
|
||||
inboxId: 1,
|
||||
callSid: 'CALL123',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(useAlert).toHaveBeenCalledWith(
|
||||
'Jane Agent is already handling the call.'
|
||||
);
|
||||
expect(TwilioVoiceClient.joinClientCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -1,51 +1,62 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
import { formatDuration } from 'shared/helpers/timeHelper';
|
||||
|
||||
export function useCallSession() {
|
||||
export function useCallSession({ manageSessionState = true } = {}) {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
});
|
||||
const handleCallDisconnected = () => callsStore.clearActiveCall();
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
if (manageSessionState) {
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener(
|
||||
'call:disconnected',
|
||||
handleCallDisconnected
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener(
|
||||
'call:disconnected',
|
||||
handleCallDisconnected
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
if (manageSessionState) {
|
||||
durationTimer.stop();
|
||||
}
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
@@ -69,10 +80,13 @@ export function useCallSession() {
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
if (manageSessionState) {
|
||||
durationTimer.start();
|
||||
}
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
@@ -91,9 +105,7 @@ export function useCallSession() {
|
||||
};
|
||||
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
return formatDuration(callDuration.value);
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -133,20 +133,55 @@ export const ARTICLE_TABS_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCALE_MENU_ITEMS = [
|
||||
{
|
||||
export const LOCALE_MENU_ITEMS = {
|
||||
makeDefault: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
|
||||
action: 'change-default',
|
||||
value: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
},
|
||||
{
|
||||
moveToDraft: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
|
||||
action: 'move-to-draft',
|
||||
value: 'draft',
|
||||
icon: 'i-lucide-eye-off',
|
||||
},
|
||||
publishLocale: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
|
||||
action: 'publish-locale',
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const disableLocaleMenuItems = menuItems =>
|
||||
menuItems.map(item => ({ ...item, disabled: true }));
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
|
||||
published: ['archive', 'draft'],
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
|
||||
import {
|
||||
buildLocaleMenuItems,
|
||||
buildPortalArticleURL,
|
||||
buildPortalURL,
|
||||
} from '../portalHelper';
|
||||
|
||||
describe('PortalHelper', () => {
|
||||
describe('buildPortalURL', () => {
|
||||
@@ -68,4 +72,39 @@ describe('PortalHelper', () => {
|
||||
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import {
|
||||
handleVoiceCallCreated,
|
||||
handleVoiceCallUpdated,
|
||||
syncConversationCallVisibility,
|
||||
} from '../voice';
|
||||
|
||||
vi.mock('dashboard/api/channel/voice/twilioVoiceClient', () => ({
|
||||
default: {
|
||||
endClientCall: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const buildVoiceMessage = overrides => ({
|
||||
content_type: CONTENT_TYPES.VOICE_CALL,
|
||||
content_attributes: {
|
||||
data: {
|
||||
call_sid: 'CALL123',
|
||||
status: 'ringing',
|
||||
call_direction: 'inbound',
|
||||
},
|
||||
},
|
||||
conversation_id: 42,
|
||||
conversation: {
|
||||
assignee_id: null,
|
||||
},
|
||||
sender: {
|
||||
id: 7,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('voice helper', () => {
|
||||
const commit = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows inbound calls to everyone when the conversation is unassigned', () => {
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
handleVoiceCallCreated(buildVoiceMessage(), 1);
|
||||
|
||||
expect(callsStore.calls).toEqual([
|
||||
expect.objectContaining({
|
||||
callSid: 'CALL123',
|
||||
conversationId: 42,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides inbound calls when they are assigned to another agent', () => {
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
handleVoiceCallCreated(
|
||||
buildVoiceMessage({
|
||||
conversation: {
|
||||
assignee_id: 9,
|
||||
},
|
||||
}),
|
||||
1
|
||||
);
|
||||
|
||||
expect(callsStore.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes a visible call when an update shows it is assigned to another agent', () => {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: 'CALL123',
|
||||
conversationId: 42,
|
||||
callDirection: 'inbound',
|
||||
senderId: 7,
|
||||
});
|
||||
|
||||
handleVoiceCallUpdated(
|
||||
commit,
|
||||
buildVoiceMessage({
|
||||
conversation: {
|
||||
assignee_id: 9,
|
||||
},
|
||||
}),
|
||||
1
|
||||
);
|
||||
|
||||
expect(callsStore.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('removes the call widget when the conversation assignment changes to another agent', () => {
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid: 'CALL123',
|
||||
conversationId: 42,
|
||||
callDirection: 'inbound',
|
||||
senderId: 7,
|
||||
});
|
||||
|
||||
syncConversationCallVisibility(
|
||||
{
|
||||
id: 42,
|
||||
meta: {
|
||||
assignee: {
|
||||
id: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
1
|
||||
);
|
||||
|
||||
expect(callsStore.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,26 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
return (
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId) &&
|
||||
!isAssignedToAnotherAgent(assigneeId, currentUserId)
|
||||
);
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
return {
|
||||
@@ -29,6 +49,7 @@ function extractCallData(message) {
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@@ -36,10 +57,19 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
@@ -53,8 +83,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
@@ -64,9 +100,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCallsForConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewCall = status === 'ringing';
|
||||
|
||||
if (isNewCall) {
|
||||
callsStore.addCall({
|
||||
@@ -77,3 +123,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"LOADING_EDITOR": "Loading editor...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"COUNT": "{n} bot | {n} bots",
|
||||
"SEARCH_PLACEHOLDER": "Search bots...",
|
||||
"NO_RESULTS": "No bots found matching your search",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "System",
|
||||
"AVATAR": {
|
||||
@@ -34,7 +37,8 @@
|
||||
"LOADING": "Fetching bots...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "Webhook URL"
|
||||
"URL": "Webhook URL",
|
||||
"ACTIONS": "Actions"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ADMINISTRATOR": "Administrator",
|
||||
"AGENT": "Agent"
|
||||
},
|
||||
"COUNT": "{n} agent | {n} agents",
|
||||
"LIST": {
|
||||
"404": "There are no agents associated to this account",
|
||||
"TITLE": "Manage agents in your team",
|
||||
@@ -96,6 +97,8 @@
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search agents...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "No results found."
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
@@ -63,6 +66,10 @@
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Automation",
|
||||
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
|
||||
"LEARN_MORE": "Learn more about automation",
|
||||
"HEADER_BTN_TXT": "Add Automation Rule",
|
||||
"COUNT": "{n} automation | {n} automations",
|
||||
"HEADER_BTN_TXT": "Create Automation",
|
||||
"LOADING": "Fetching automation rules",
|
||||
"SEARCH_PLACEHOLDER": "Search automation rules...",
|
||||
"NO_RESULTS": "No automation rules found matching your search",
|
||||
"ADD": {
|
||||
"TITLE": "Add Automation Rule",
|
||||
"SUBMIT": "Create",
|
||||
@@ -42,9 +45,9 @@
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"ACTIVE": "Active",
|
||||
"CREATED_ON": "Created on"
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
},
|
||||
@@ -150,7 +153,8 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
"OPEN_CONVERSATION": "Open conversation",
|
||||
"PENDING_CONVERSATION": "Mark conversation as pending"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
"RESOLVE": {
|
||||
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
|
||||
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Canned Responses",
|
||||
"LEARN_MORE": "Learn more about canned responses",
|
||||
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
|
||||
"COUNT": "{n} canned response | {n} canned responses",
|
||||
"HEADER_BTN_TXT": "Add canned response",
|
||||
"LOADING": "Fetching canned responses...",
|
||||
"SEARCH_PLACEHOLDER": "Search canned responses...",
|
||||
"NO_RESULTS": "No canned responses found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"LIST": {
|
||||
"404": "There are no canned responses available in this account.",
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "Pending Response: Shortest first"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,7 @@
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -173,6 +174,10 @@
|
||||
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign label. Please try again."
|
||||
},
|
||||
"LABEL_REMOVAL": {
|
||||
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||
"FAILED": "Couldn't remove label. Please try again."
|
||||
},
|
||||
"TEAM_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign team. Please try again."
|
||||
@@ -185,7 +190,11 @@
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
@@ -205,7 +214,7 @@
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"": "",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
@@ -247,9 +256,12 @@
|
||||
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
|
||||
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
||||
"FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation",
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
"BOT": "Bot",
|
||||
"NATIVE_APP": "Native app",
|
||||
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
|
||||
"SEND_FAILED": "Couldn't send message! Try again",
|
||||
"TRY_AGAIN": "retry",
|
||||
"ASSIGNMENT": {
|
||||
@@ -294,6 +306,7 @@
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Custom Roles",
|
||||
"LEARN_MORE": "Learn more about custom roles",
|
||||
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"COUNT": "{n} custom role | {n} custom roles",
|
||||
"HEADER_BTN_TXT": "Add custom role",
|
||||
"LOADING": "Fetching custom roles...",
|
||||
"SEARCH_PLACEHOLDER": "Search custom roles...",
|
||||
"NO_RESULTS": "No custom roles found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create custom roles",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -13,6 +16,8 @@
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
},
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "Accept",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "Preferred"
|
||||
},
|
||||
"CHOICE_TOGGLE": {
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
},
|
||||
"COMMAND_BAR": {
|
||||
"SEARCH_PLACEHOLDER": "Search or jump to",
|
||||
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,9 @@
|
||||
"FETCHING": "Fetching Integrations",
|
||||
"NO_HOOK_CONFIGURED": "There are no {integrationId} integrations configured in this account.",
|
||||
"HEADER": "Applications",
|
||||
"COUNT": "{n} integration | {n} integrations",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"NO_RESULTS": "No results found matching your search",
|
||||
"STATUS": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
@@ -31,6 +34,7 @@
|
||||
"LIST": {
|
||||
"FETCHING": "Fetching integration hooks",
|
||||
"INBOX": "Inbox",
|
||||
"ACTIONS": "Actions",
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
||||
"LOADING": "Fetching labels",
|
||||
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
|
||||
"LEARN_MORE": "Learn more about labels",
|
||||
"COUNT": "{n} label | {n} labels",
|
||||
"SEARCH_PLACEHOLDER": "Search labels...",
|
||||
"NO_RESULTS": "No labels found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query",
|
||||
"LIST": {
|
||||
"404": "There are no labels available in this account.",
|
||||
@@ -13,7 +16,8 @@
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"COLOR": "Color"
|
||||
"COLOR": "Color",
|
||||
"ACTION": "Actions"
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"HEADER": "Macros",
|
||||
"DESCRIPTION": "A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click.",
|
||||
"LEARN_MORE": "Learn more about macros",
|
||||
"COUNT": "{n} macro | {n} macros",
|
||||
"HEADER_BTN_TXT": "Add a new macro",
|
||||
"HEADER_BTN_TXT_SAVE": "Save macro",
|
||||
"LOADING": "Fetching macros",
|
||||
"SEARCH_PLACEHOLDER": "Search macros...",
|
||||
"NO_RESULTS": "No macros found matching your search",
|
||||
"ERROR": "Something went wrong. Please try again",
|
||||
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
|
||||
"ADD": {
|
||||
@@ -29,7 +32,8 @@
|
||||
"NAME": "Name",
|
||||
"CREATED BY": "Created by",
|
||||
"LAST_UPDATED_BY": "Last updated by",
|
||||
"VISIBILITY": "Visibility"
|
||||
"VISIBILITY": "Visibility",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No macros found"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"MFA_SETTINGS": {
|
||||
"TITLE": "Two-Factor Authentication",
|
||||
"SUBTITLE": "Secure your account with TOTP-based authentication",
|
||||
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
|
||||
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
|
||||
"STATUS_TITLE": "Authentication Status",
|
||||
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"HEADER": "Conversations",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
|
||||
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
||||
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
||||
"METRICS": {
|
||||
@@ -128,11 +128,16 @@
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Agent",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -201,6 +206,11 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Label",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"LABELS": "Search labels"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -271,6 +281,11 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -334,11 +349,19 @@
|
||||
},
|
||||
"TEAM_REPORTS": {
|
||||
"HEADER": "Team Overview",
|
||||
"DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Team",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"TEAMS": "Search teams"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -402,22 +425,48 @@
|
||||
},
|
||||
"CSAT_REPORTS": {
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
"NO_RECORDS": "No responses yet",
|
||||
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
|
||||
"DOWNLOAD": "Download CSAT Reports",
|
||||
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents",
|
||||
"INBOXES": "Search inboxes",
|
||||
"TEAMS": "Search teams",
|
||||
"RATINGS": "Search ratings"
|
||||
},
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "Choose Agents"
|
||||
"LABEL": "Agent"
|
||||
},
|
||||
"INBOXES": {
|
||||
"LABEL": "Inbox"
|
||||
},
|
||||
"TEAMS": {
|
||||
"LABEL": "Team"
|
||||
},
|
||||
"RATINGS": {
|
||||
"LABEL": "Rating"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"CONTACT_NAME": "Contact",
|
||||
"AGENT_NAME": "Assigned agent",
|
||||
"AGENT_NAME": "Agent",
|
||||
"RATING": "Rating",
|
||||
"FEEDBACK_TEXT": "Feedback comment"
|
||||
}
|
||||
"FEEDBACK_TEXT": "Feedback comment",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CUSTOMER": "Customer",
|
||||
"RESPONSE": "Response",
|
||||
"HANDLED_BY": "Handled by"
|
||||
},
|
||||
"UNKNOWN_CUSTOMER": "Unknown customer"
|
||||
},
|
||||
"NO_AGENT": "No assigned agent",
|
||||
"NO_FEEDBACK": "No feedback provided",
|
||||
"METRIC": {
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Total responses",
|
||||
@@ -430,6 +479,25 @@
|
||||
"RESPONSE_RATE": {
|
||||
"LABEL": "Response rate",
|
||||
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
|
||||
},
|
||||
"RATING_DISTRIBUTION": "Rating distribution"
|
||||
},
|
||||
"REVIEW_NOTES": {
|
||||
"TITLE": "Review notes",
|
||||
"PLACEHOLDER": "Add review notes about this rating...",
|
||||
"SAVE": "Save",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVING": "Saving...",
|
||||
"SAVED": "Notes saved successfully",
|
||||
"SAVE_ERROR": "Failed to save notes",
|
||||
"UPDATED_BY": "Updated by {name} {time}",
|
||||
"UPDATED_BY_LABEL": "Updated by",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to add review notes",
|
||||
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Create an account",
|
||||
"GET_STARTED": "Get started with Chatwoot",
|
||||
"TITLE": "Register",
|
||||
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
|
||||
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
"ADD_ACTION_LONG": "Create a new SLA Policy",
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"COUNT": "{n} SLA | {n} SLAs",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"SEARCH_PLACEHOLDER": "Search SLA...",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "No SLA found matching your search"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create SLAs",
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
|
||||
@@ -20,14 +25,18 @@
|
||||
},
|
||||
"LIST": {
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"TABLE_HEADER": {
|
||||
"SLA": "SLA",
|
||||
"BUSINESS_HOURS": "Business hours"
|
||||
},
|
||||
"EMPTY": {
|
||||
"TITLE_1": "Enterprise P0",
|
||||
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
|
||||
"TITLE_2": "Enterprise P1",
|
||||
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
|
||||
},
|
||||
"BUSINESS_HOURS_ON": "Business hours on",
|
||||
"BUSINESS_HOURS_OFF": "Business hours off",
|
||||
"BUSINESS_HOURS_ON": "Turned on",
|
||||
"BUSINESS_HOURS_OFF": "Turned off",
|
||||
"RESPONSE_TYPES": {
|
||||
"FRT": "First response time threshold",
|
||||
"NRT": "Next response time threshold",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minute",
|
||||
"MINUTES": "minutes",
|
||||
"HOUR": "hour",
|
||||
"HOURS": "hours",
|
||||
"DAY": "day",
|
||||
"DAYS": "days",
|
||||
"WEEK": "week",
|
||||
"WEEKS": "weeks",
|
||||
"MONTH": "month",
|
||||
"MONTHS": "months",
|
||||
"YEAR": "year",
|
||||
"YEARS": "years"
|
||||
},
|
||||
"HALF": "half",
|
||||
"NEXT": "next",
|
||||
"THIS": "this",
|
||||
"AT": "at",
|
||||
"IN": "in",
|
||||
"FROM_NOW": "from now",
|
||||
"NEXT_YEAR": "next year",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "tomorrow",
|
||||
"DAY_AFTER_TOMORROW": "day after tomorrow",
|
||||
"NEXT_WEEK": "next week",
|
||||
"NEXT_MONTH": "next month",
|
||||
"THIS_WEEKEND": "this weekend",
|
||||
"NEXT_WEEKEND": "next weekend"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "morning",
|
||||
"AFTERNOON": "afternoon",
|
||||
"EVENING": "evening",
|
||||
"NIGHT": "night",
|
||||
"NOON": "noon",
|
||||
"MIDNIGHT": "midnight"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "one",
|
||||
"TWO": "two",
|
||||
"THREE": "three",
|
||||
"FOUR": "four",
|
||||
"FIVE": "five",
|
||||
"SIX": "six",
|
||||
"SEVEN": "seven",
|
||||
"EIGHT": "eight",
|
||||
"NINE": "nine",
|
||||
"TEN": "ten",
|
||||
"TWELVE": "twelve",
|
||||
"FIFTEEN": "fifteen",
|
||||
"TWENTY": "twenty",
|
||||
"THIRTY": "thirty"
|
||||
},
|
||||
"ORDINALS": {
|
||||
"FIRST": "first",
|
||||
"SECOND": "second",
|
||||
"THIRD": "third",
|
||||
"FOURTH": "fourth",
|
||||
"FIFTH": "fifth"
|
||||
},
|
||||
"OF": "of",
|
||||
"AFTER": "after",
|
||||
"WEEK": "week",
|
||||
"DAY": "day"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "Fetching teams",
|
||||
"DESCRIPTION": "Teams allow you to organize agents into groups based on their responsibilities. An agent can belong to multiple teams. When working collaboratively, you can assign conversations to specific teams.",
|
||||
"LEARN_MORE": "Learn more about teams",
|
||||
"COUNT": "{n} team | {n} teams",
|
||||
"SEARCH_PLACEHOLDER": "Search teams...",
|
||||
"NO_RESULTS": "No teams found matching your search",
|
||||
"LIST": {
|
||||
"404": "There are no teams created on this account.",
|
||||
"EDIT_TEAM": "Edit team",
|
||||
@@ -64,8 +67,8 @@
|
||||
"ERROR_MESSAGE": "Couldn't save the team details. Try again."
|
||||
},
|
||||
"AGENTS": {
|
||||
"AGENT": "AGENT",
|
||||
"EMAIL": "EMAIL",
|
||||
"AGENT": "Agent",
|
||||
"EMAIL": "Email",
|
||||
"BUTTON_TEXT": "Add agents",
|
||||
"ADD_AGENTS": "Adding Agents to your Team...",
|
||||
"SELECT": "select",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"LOADING_EDITOR": "جار جلب المحرر...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"COUNT": "{n} bot | {n} bots",
|
||||
"SEARCH_PLACEHOLDER": "Search bots...",
|
||||
"NO_RESULTS": "No bots found matching your search",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "النظام",
|
||||
"AVATAR": {
|
||||
@@ -34,7 +37,8 @@
|
||||
"LOADING": "جار جلب الروبوتات...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "رابط Webhook"
|
||||
"URL": "رابط Webhook",
|
||||
"ACTIONS": "الإجراءات"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ADMINISTRATOR": "المدير",
|
||||
"AGENT": "وكيل الدعم"
|
||||
},
|
||||
"COUNT": "{n} وكيل | {n} وكلاء",
|
||||
"LIST": {
|
||||
"404": "لا يوجد وكلاء دعم مرتبطين بهذا الحساب",
|
||||
"TITLE": "إدارة وكلاء الدعم في فريقك",
|
||||
@@ -96,6 +97,8 @@
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "البحث عن وكلاء...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "لم يتم العثور على النتائج."
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "جلب الصفات المخصصة",
|
||||
"DESCRIPTION": "سمة مخصصة تتتبع تفاصيل إضافية حول جهات الاتصال أو المحادثات الخاصة بك - مثل خطة الاشتراك أو تاريخ الشراء الأول. يمكنك إضافة أنواع مختلفة من السمات المخصصة، مثل النص أو القوائم أو الأرقام، لالتقاط المعلومات المحددة التي تحتاجها.",
|
||||
"LEARN_MORE": "تعرف على المزيد حول السمات المخصصة",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "البحث عن صفات...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "المحادثات",
|
||||
"CONTACT": "جهات الاتصال"
|
||||
@@ -63,6 +66,10 @@
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "تمكين التحقق من صحة regex"
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -23,52 +23,52 @@
|
||||
},
|
||||
"DEFAULT_USER": "النظام",
|
||||
"AUTOMATION_RULE": {
|
||||
"ADD": "{agentName} أنشأ قاعدة أتمتة جديدة (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث قاعدة أتمتة (##{id})",
|
||||
"DELETE": "{agentName} حذف قاعدة أتمتة (##{id})"
|
||||
"ADD": "{agentName} أنشأ قاعدة أتمتة جديدة (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث قاعدة أتمتة (#{id})",
|
||||
"DELETE": "{agentName} حذف قاعدة أتمتة (#{id})"
|
||||
},
|
||||
"ACCOUNT_USER": {
|
||||
"ADD": "{agentName} دعا {invitee} إلى الحساب كـ {role}",
|
||||
"EDIT": {
|
||||
"SELF": "{agentName} غير {attributes} الخاصة به إلى {values}",
|
||||
"OTHER": "{agentName} غير {attributes} لـ {user} إلى {values}",
|
||||
"DELETED": "{agentName} غير {attributes} لـ %{user} إلى {values}"
|
||||
"DELETED": "{agentName} غير {attributes} للمستخدم المحذوف إلى {values}"
|
||||
}
|
||||
},
|
||||
"INBOX": {
|
||||
"ADD": "{agentName} أنشأ صندوق وارد جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث صندوق الوارد (##{id})",
|
||||
"DELETE": "{agentName} حذف صندوق الوارد (##{id})"
|
||||
"ADD": "{agentName} أنشأ صندوق وارد جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث صندوق الوارد (#{id})",
|
||||
"DELETE": "{agentName} حذف صندوق الوارد (#{id})"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"ADD": "{agentName} أنشأ Webhook جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث Webhook (##{id})",
|
||||
"DELETE": "{agentName} حذف Webhook (##{id})"
|
||||
"ADD": "{agentName} أنشأ Webhook جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث Webhook (#{id})",
|
||||
"DELETE": "{agentName} حذف Webhook (#{id})"
|
||||
},
|
||||
"USER_ACTION": {
|
||||
"SIGN_IN": "{agentName} قام بتسجيل الدخول",
|
||||
"SIGN_OUT": "{agentName} قام بتسجيل الخروج"
|
||||
},
|
||||
"TEAM": {
|
||||
"ADD": "{agentName} أنشأ فريق جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث الفريق (##{id})",
|
||||
"DELETE": "{agentName} حذف الفريق (##{id})"
|
||||
"ADD": "{agentName} أنشأ فريق جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث الفريق (#{id})",
|
||||
"DELETE": "{agentName} حذف الفريق (#{id})"
|
||||
},
|
||||
"MACRO": {
|
||||
"ADD": "{agentName} أنشأ ماكرو جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث ماكرو (##{id})",
|
||||
"DELETE": "{agentName} حذف ماكرو (##{id})"
|
||||
"ADD": "{agentName} أنشأ ماكرو جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث ماكرو (#{id})",
|
||||
"DELETE": "{agentName} حذف ماكرو (#{id})"
|
||||
},
|
||||
"INBOX_MEMBER": {
|
||||
"ADD": "{agentName} أضاف {user} إلى صندوق الوارد (##{inbox_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من صندوق الوارد (##{inbox_id})"
|
||||
"ADD": "{agentName} أضاف {user} إلى صندوق الوارد (#{inbox_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من صندوق الوارد (#{inbox_id})"
|
||||
},
|
||||
"TEAM_MEMBER": {
|
||||
"ADD": "{agentName} أضاف {user} إلى الفريق (##{team_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من الفريق (##{team_id})"
|
||||
"ADD": "{agentName} أضاف {user} إلى الفريق (#{team_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من الفريق (#{team_id})"
|
||||
},
|
||||
"ACCOUNT": {
|
||||
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (##{id})"
|
||||
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (#{id})"
|
||||
},
|
||||
"CONVERSATION": {
|
||||
"DELETE": "{agentName} deleted conversation #{id}"
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "الأتمتة",
|
||||
"DESCRIPTION": "ويمكن للأتمتة أن تحل محل وتبسط العمليات القائمة التي تتطلب جهداً يدوياً، مثل إضافة تسميات وتعيين المحادثات إلى أنسب وكيل. ويسمح ذلك للفريق بالتركيز على مواطن قوتهم مع تقليل الوقت الذي يقضيه في المهام الروتينية.",
|
||||
"LEARN_MORE": "تعلم المزيد عن الأتمتة",
|
||||
"HEADER_BTN_TXT": "إضافة قاعدة أتمتة",
|
||||
"COUNT": "{n} automation | {n} automations",
|
||||
"HEADER_BTN_TXT": "Create Automation",
|
||||
"LOADING": "جلب قواعد الأتمتة",
|
||||
"SEARCH_PLACEHOLDER": "Search automation rules...",
|
||||
"NO_RESULTS": "No automation rules found matching your search",
|
||||
"ADD": {
|
||||
"TITLE": "إضافة قاعدة أتمتة",
|
||||
"SUBMIT": "إنشاء",
|
||||
@@ -42,9 +45,9 @@
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "الاسم",
|
||||
"DESCRIPTION": "الوصف",
|
||||
"ACTIVE": "مفعل",
|
||||
"CREATED_ON": "تم إنشاؤها في"
|
||||
"CREATED_ON": "تم إنشاؤها في",
|
||||
"ACTIONS": "الإجراءات"
|
||||
},
|
||||
"404": "لم يتم العثور على قواعد أتمتة"
|
||||
},
|
||||
@@ -150,7 +153,8 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "تغيير الأولوية",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "فتح المحادثة"
|
||||
"OPEN_CONVERSATION": "فتح المحادثة",
|
||||
"PENDING_CONVERSATION": "تحديد المحادثة كمعلقة"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
|
||||
"UPDATE_FAILED": "فشل تحديث المحادثات، الرجاء المحاولة مرة أخرى."
|
||||
},
|
||||
"RESOLVE": {
|
||||
"ALL_MISSING_ATTRIBUTES": "لا يمكن حل المحادثات بسبب عدم وجود السمات المطلوبة",
|
||||
"PARTIAL_SUCCESS": "بعض المحادثات تحتاج إلى سمات مطلوبة قبل الحل وتم تخطيها"
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "إضافة وسم",
|
||||
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "الردود الجاهزة",
|
||||
"LEARN_MORE": "معرفة المزيد عن الاستجابات المعلبة",
|
||||
"DESCRIPTION": "الردود المسبقة هي قوالب رد مكتوبة مسبقاً تساعدك على الرد بسرعة على محادثة. يمكن للوكلاء كتابة حرف '/' يتبعه الرمز المختصر لإدراج رد مسبق أثناء محادثة. ",
|
||||
"COUNT": "{n} canned response | {n} canned responses",
|
||||
"HEADER_BTN_TXT": "إضافة رد جاهز",
|
||||
"LOADING": "جاري جلب الردود الجاهزة...",
|
||||
"SEARCH_PLACEHOLDER": "Search canned responses...",
|
||||
"NO_RESULTS": "No canned responses found matching your search",
|
||||
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام.",
|
||||
"LIST": {
|
||||
"404": "لا توجد ردود جاهزة متوفرة في هذا الحساب.",
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "الرد المعلق: الأقصر أولاً"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
@@ -104,7 +107,7 @@
|
||||
"CONTENT": "Shared contact"
|
||||
},
|
||||
"embed": {
|
||||
"CONTENT": "Embedded content"
|
||||
"CONTENT": "المحتوى المضمن"
|
||||
}
|
||||
},
|
||||
"CHAT_SORT_BY_FILTER": {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"COMPANIES": {
|
||||
"HEADER": "Companies",
|
||||
"HEADER": "الشركات",
|
||||
"SORT_BY": {
|
||||
"LABEL": "ترتيب حسب",
|
||||
"OPTIONS": {
|
||||
"NAME": "الاسم",
|
||||
"DOMAIN": "النطاق",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
"CONTACTS_COUNT": "عدد جهات الاتصال"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
"LABEL": "Order",
|
||||
"LABEL": "ترتيب",
|
||||
"OPTIONS": {
|
||||
"ASCENDING": "Ascending",
|
||||
"ASCENDING": "تصاعدي",
|
||||
"DESCENDING": "Descending"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search companies...",
|
||||
"LOADING": "Loading companies...",
|
||||
"UNNAMED": "Unnamed Company",
|
||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||
"SEARCH_PLACEHOLDER": "البحث في الشركات...",
|
||||
"LOADING": "جاري تحميل الشركات...",
|
||||
"UNNAMED": "شركة بلا اسم",
|
||||
"CONTACTS_COUNT": "جهة اتصال {n} | {n} جهات الاتصال",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No companies found"
|
||||
"TITLE": "لم يتم العثور على شركات"
|
||||
}
|
||||
},
|
||||
"COMPANIES_LAYOUT": {
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
|
||||
"SHOWING": "عرض {startItem} - {endItem} من {totalItems} شركة | عرض {startItem} – {endItem} من الشركات {totalItems}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"CREATED_AT_LABEL": "تم إنشاؤها",
|
||||
"NEW_MESSAGE": "رسالة جديدة",
|
||||
"CALL": "Call",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
|
||||
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
@@ -457,8 +457,11 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"PLACEHOLDER": "Add Telegram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
"PLACEHOLDER": "إضافة TikTok"
|
||||
},
|
||||
"LINKEDIN": {
|
||||
"PLACEHOLDER": "Add LinkedIn"
|
||||
@@ -573,7 +576,8 @@
|
||||
"SEARCH_EMPTY_STATE_TITLE": "لا توجد جهات اتصال تطابق بحثك 🔍",
|
||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"LOAD_MORE": "تحميل المزيد"
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "تعيين التسميات",
|
||||
@@ -607,7 +611,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"LABEL": "إلى:",
|
||||
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
|
||||
"CONTACT_CREATING": "Creating contact..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -618,9 +622,9 @@
|
||||
"SUBJECT_LABEL": "الموضوع :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"CC_LABEL": "نسخة من البريد:",
|
||||
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_LABEL": "نسخة خفية من البريد:",
|
||||
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_BUTTON": "نسخة خفية من البريد"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "هذه الرسالة غير مدعومة، يمكنك مشاهدة هذه الرسالة على تطبيق فيسبوك (Messenger).",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "هذه الرسالة غير مدعومة، يمكنك عرض هذه الرسالة على تطبيق Instagram.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "هذه الرسالة غير مدعومة. يمكنك مشاهدة هذه الرسالة على تطبيق TikTok.",
|
||||
"SUCCESS_DELETE_MESSAGE": "تم حذف الرسالة بنجاح",
|
||||
"FAIL_DELETE_MESSSAGE": "تعذر حذف الرسالة! حاول مرة أخرى",
|
||||
"NO_RESPONSE": "لا توجد استجابة",
|
||||
@@ -170,9 +171,13 @@
|
||||
"FAILED": "تعذر تعيين الوكيل. الرجاء المحاولة مرة أخرى."
|
||||
},
|
||||
"LABEL_ASSIGNMENT": {
|
||||
"SUCCESFUL": "تعيين تسمية ##{labelName} لمعرف المحادثة {conversationId}",
|
||||
"SUCCESFUL": "تعيين تسمية #{labelName} لمعرف المحادثة {conversationId}",
|
||||
"FAILED": "تعذر تعيين التسمية. الرجاء المحاولة مرة أخرى."
|
||||
},
|
||||
"LABEL_REMOVAL": {
|
||||
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||
"FAILED": "Couldn't remove label. Please try again."
|
||||
},
|
||||
"TEAM_ASSIGNMENT": {
|
||||
"SUCCESFUL": "الفريق المعين \"{team}\" لمعرف المحادثة {conversationId}",
|
||||
"FAILED": "تعذر تعيين الفريق. الرجاء المحاولة مرة أخرى."
|
||||
@@ -185,7 +190,11 @@
|
||||
"DISABLE_SIGN_TOOLTIP": "تعطيل التوقيع",
|
||||
"MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. ابدأ بزر / للاختيار من الردود الجاهزة.",
|
||||
"PRIVATE_MSG_INPUT": "زر Shift + Enter لإضافة سطر جديد. سيكون هذا مرئياً للوكلاء فقط",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "لم يتم تكوين توقيع الرسالة، الرجاء تكوينه في إعدادات الملف الشخصي.",
|
||||
"COPILOT_MSG_INPUT": "إعطاء copilot أوامر إضافية، أو السؤال عن أي شيء آخر... اضغط على مفتاح الإدخال لإرسال المتابعة",
|
||||
"CLICK_HERE": "انقر هنا للتحديث",
|
||||
"WHATSAPP_TEMPLATES": "قوالب الواتساب"
|
||||
},
|
||||
@@ -205,7 +214,7 @@
|
||||
"DRAG_DROP": "اسحب و أسقط هنا للإرفاق",
|
||||
"START_AUDIO_RECORDING": "بدء التسجيل الصوتي",
|
||||
"STOP_AUDIO_RECORDING": "إيقاف التسجيل الصوتي",
|
||||
"": "",
|
||||
"COPILOT_THINKING": "Copilot يفكر",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "إلى",
|
||||
"ADD_BCC": "إضافة bcc",
|
||||
@@ -247,9 +256,12 @@
|
||||
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
|
||||
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
|
||||
"FILE_SIZE_LIMIT": "حجم الملف يتجاوز حد الاقصى وهو {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE}",
|
||||
"FILE_TYPE_NOT_SUPPORTED": "هذا النوع من الملفات {fileName} غير مدعوم في هذه المحادثة",
|
||||
"MESSAGE_ERROR": "غير قادر على إرسال هذه الرسالة، الرجاء المحاولة مرة أخرى لاحقاً",
|
||||
"SENT_BY": "أرسلت بواسطة:",
|
||||
"BOT": "رد آلي",
|
||||
"NATIVE_APP": "تطبيق الجوال",
|
||||
"NATIVE_APP_ADVISORY": "تم إرسال هذه الرسالة من تطبيق الجوال. رد من Chatwoot للحفاظ على نافذة الرسالة.",
|
||||
"SEND_FAILED": "تعذر إرسال الرسالة! حاول مرة أخرى",
|
||||
"TRY_AGAIN": "إعادة المحاولة",
|
||||
"ASSIGNMENT": {
|
||||
@@ -277,14 +289,14 @@
|
||||
"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"
|
||||
"INCOMING_CALL": "مكالمة واردة",
|
||||
"OUTGOING_CALL": "مكالمة صادرة",
|
||||
"CALL_IN_PROGRESS": "مكالمة قيد الاتصال",
|
||||
"NOT_ANSWERED_YET": "لم يتم الرد بعد",
|
||||
"HANDLED_IN_ANOTHER_TAB": "يتم التعامل معها في علامة تبويب أخرى",
|
||||
"REJECT_CALL": "رفض",
|
||||
"JOIN_CALL": "انضم إلى المكالمة",
|
||||
"END_CALL": "إنهاء المكالمة"
|
||||
}
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
@@ -294,6 +306,7 @@
|
||||
"CANCEL": "إلغاء",
|
||||
"SEND_EMAIL_SUCCESS": "تم إرسال نص المحادثة بنجاح",
|
||||
"SEND_EMAIL_ERROR": "حدث خطأ، الرجاء المحاولة مرة أخرى",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "إرسال نص المحادثة إلى العميل",
|
||||
"SEND_TO_AGENT": "إرسال نص المحادثة إلى وكيل خدمة العملاء المعين",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Custom Roles",
|
||||
"LEARN_MORE": "Learn more about custom roles",
|
||||
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"COUNT": "{n} custom role | {n} custom roles",
|
||||
"HEADER_BTN_TXT": "Add custom role",
|
||||
"LOADING": "Fetching custom roles...",
|
||||
"SEARCH_PLACEHOLDER": "Search custom roles...",
|
||||
"NO_RESULTS": "No custom roles found matching your search",
|
||||
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام.",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create custom roles",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "تطبيق",
|
||||
"CLEAR_BUTTON": "مسح",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -13,6 +16,8 @@
|
||||
"LAST_3_MONTHS": "آخر 3 أشهر",
|
||||
"LAST_6_MONTHS": "آخر 6 أشهر",
|
||||
"LAST_YEAR": "العام الماضي",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "تحديد نطاق التاريخ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
},
|
||||
"CLOSE": "أغلق",
|
||||
"BETA": "تجريبي",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "قبول",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "المفضلة"
|
||||
},
|
||||
"CHOICE_TOGGLE": {
|
||||
"YES": "نعم",
|
||||
"NO": "لا"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
},
|
||||
"COMMAND_BAR": {
|
||||
"SEARCH_PLACEHOLDER": "البحث أو القفز إلى",
|
||||
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "عام",
|
||||
"REPORTS": "التقارير",
|
||||
|
||||
@@ -374,6 +374,16 @@
|
||||
"ERROR_MESSAGE": "Error while deleting article"
|
||||
}
|
||||
},
|
||||
"REORDER_ARTICLE": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
|
||||
}
|
||||
},
|
||||
"REORDER_CATEGORY": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
|
||||
}
|
||||
},
|
||||
"CREATE_ARTICLE": {
|
||||
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"HEADER": "قنوات التواصل",
|
||||
"DESCRIPTION": "القناة هي وضع الاتصال الذي يختاره العميل للتفاعل معك. صندوق الوارد هو المكان الذي تدير فيه التفاعلات لقناة معينة. ويمكن أن تشمل الاتصالات من مصادر مختلفة مثل البريد الإلكتروني، والمحادثة الحية، ووسائط الإعلام الاجتماعية.",
|
||||
"LEARN_MORE": "تعلم المزيد عن صناديق البريد",
|
||||
"COUNT": "{n} inbox | {n} inboxes",
|
||||
"SEARCH_PLACEHOLDER": "Search inboxes...",
|
||||
"NO_RESULTS": "No inboxes found matching your search",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
@@ -58,11 +61,11 @@
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
|
||||
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
|
||||
"CONTINUE_WITH_TIKTOK": "المتابعة مع تيكتوك",
|
||||
"CONNECT_YOUR_TIKTOK_PROFILE": "الاتصال بحسابك في تيكتوك",
|
||||
"HELP": "لإضافة ملفك الشخصي على TikTok كقناة، عليك مصادقة ملفك الشخصي على TikTok بالنقر على \"متابعة مع TikTok\".",
|
||||
"ERROR_MESSAGE": "حدث خطأ أثناء الاتصال بـ TikTok، يرجى المحاولة مرة أخرى",
|
||||
"ERROR_AUTH": "حدث خطأ أثناء الاتصال بـ TikTok، يرجى المحاولة مرة أخرى"
|
||||
},
|
||||
"TWITTER": {
|
||||
"HELP": "لإضافة حساب تويتر الخاص بك كقناة تواصل، تحتاج إلى مصادقة حسابك على تويتر بك بالنقر على زر \"تسجيل الدخول باستخدام تويتر\" ",
|
||||
@@ -389,10 +392,10 @@
|
||||
"ERROR_MESSAGE": "لم نتمكن من حفظ قناة البريد الإلكتروني"
|
||||
},
|
||||
"FINISH_MESSAGE": "بدء إعادة توجيه رسائل البريد الإلكتروني الخاصة بك إلى عنوان البريد الإلكتروني التالي.",
|
||||
"FINISH_MESSAGE_NO_FORWARDING": "Your email inbox has been created successfully! You need to configure SMTP and IMAP credentials to send and receive emails. Without these settings, no emails will be processed.",
|
||||
"FORWARDING_ADDRESS_LABEL": "Forward emails to this address:",
|
||||
"FINISH_MESSAGE_NO_FORWARDING": "تم إنشاء بريدك الإلكتروني بنجاح! تحتاج إلى تكوين بيانات اعتماد SMTP و IMAP لإرسال واستقبال رسائل البريد الإلكتروني. بدون هذه الإعدادات، لن يتم معالجة رسائل البريد الإلكتروني.",
|
||||
"FORWARDING_ADDRESS_LABEL": "إعادة توجيه رسائل البريد الإلكتروني إلى هذا العنوان",
|
||||
"CONFIGURE_SMTP_IMAP_LINK": "اضغط هنا",
|
||||
"CONFIGURE_SMTP_IMAP_TEXT": " to configure IMAP and SMTP settings"
|
||||
"CONFIGURE_SMTP_IMAP_TEXT": " لتهيئة إعدادات IMAP و SMTP"
|
||||
},
|
||||
"LINE_CHANNEL": {
|
||||
"TITLE": "قناة LINE",
|
||||
@@ -480,7 +483,7 @@
|
||||
},
|
||||
"TIKTOK": {
|
||||
"TITLE": "TikTok",
|
||||
"DESCRIPTION": "Connect your TikTok account"
|
||||
"DESCRIPTION": "ربط حسابك في TikTok"
|
||||
},
|
||||
"VOICE": {
|
||||
"TITLE": "Voice",
|
||||
@@ -575,7 +578,7 @@
|
||||
"SUBTITLE": "Use only the configured business name as the sender name in the email header."
|
||||
},
|
||||
"BUSINESS_NAME": {
|
||||
"BUTTON_TEXT": "+ Configure your business name",
|
||||
"BUTTON_TEXT": "Configure your business name",
|
||||
"PLACEHOLDER": "Enter your business name",
|
||||
"SAVE_BUTTON_TEXT": "حفظ"
|
||||
}
|
||||
@@ -589,8 +592,10 @@
|
||||
"DISABLED": "معطّل"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "مفعل",
|
||||
"DISABLED": "معطّل"
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "تفعيل"
|
||||
@@ -625,6 +630,8 @@
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "تقييم رضاء العملاء"
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
@@ -678,6 +685,16 @@
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "مباشر"
|
||||
}
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"TITLE": "Webhook Configuration",
|
||||
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||
"ACTION_REQUIRED": "Webhook not configured",
|
||||
"REGISTER_BUTTON": "Register Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||
"URL_MISMATCH": "Webhook URL mismatch"
|
||||
}
|
||||
},
|
||||
"SETTINGS": "الإعدادات",
|
||||
@@ -693,8 +710,20 @@
|
||||
"MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك",
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
|
||||
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
|
||||
"DESCRIPTION": "Restrict which websites can embed your chat widget. For security, only add domains you own and trust. Add one or more domains separated by commas. Leave blank to allow all domains (not recommended for production).",
|
||||
"PLACEHOLDER": "example.com, www.example.com, app.example.com"
|
||||
},
|
||||
"ALLOW_MOBILE_WEBVIEW": {
|
||||
"LABEL": "Enable widget in mobile apps",
|
||||
"SUBTITLE": "Check this if you embed the widget in iOS or Android apps. Mobile apps don't send domain information, so they would be blocked by domain restrictions unless this is enabled."
|
||||
},
|
||||
"IDENTITY_VALIDATION": {
|
||||
"TITLE": "Identity Validation",
|
||||
"DESCRIPTION": "Verify user authenticity by generating secure tokens. This prevents unauthorized users from impersonating others in your chat.",
|
||||
"SECRET_KEY": "Secret Key",
|
||||
"VIEW_DOCS": "View documentation",
|
||||
"REQUIRE_LABEL": "Require identity validation for all conversations",
|
||||
"REQUIRE_DESCRIPTION": "When enabled, users must provide a valid identity token to start conversations. Requests without valid tokens will be rejected."
|
||||
},
|
||||
"INBOX_AGENTS": "وكيل الدعم",
|
||||
"INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا",
|
||||
@@ -708,8 +737,8 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "تمكين/تعطيل إظهار اسم الوكيل في البريد الإلكتروني، إذا تم تعطيله فسيظهر اسم المنشأة",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "تمكين استمرارية المحادثة عبر البريد الإلكتروني",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "المحادثات ستستمر عبر البريد الإلكتروني إذا كان عنوان البريد الإلكتروني لجهة الاتصال متاحاً.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "قفل إلى محادثة واحدة",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "تمكين أو تعطيل محادثات متعددة لنفس جهة الاتصال في هذا البريد الوارد",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "إعدادات قناة التواصل",
|
||||
"INBOX_UPDATE_SUB_TEXT": "تحديث إعدادات قناة التواصل",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "تمكين أو تعطيل الإسناد التلقائي للمحادثات الجديدة إلى الموظفين المضافين إلى قناة التواصل هذه.",
|
||||
@@ -758,6 +787,7 @@
|
||||
"LABEL": "مركز المساعدة",
|
||||
"PLACEHOLDER": "Select Help Center",
|
||||
"SELECT_PLACEHOLDER": "Select Help Center",
|
||||
"NONE": "لا شيء",
|
||||
"REMOVE": "Remove Help Center",
|
||||
"SUB_TEXT": "Attach a Help Center with the inbox"
|
||||
},
|
||||
@@ -766,6 +796,53 @@
|
||||
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "الرجاء إدخال قيمة أكبر من 0",
|
||||
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "تحديد الحد الأقصى لعدد المحادثات من علبة الوارد هذه التي يمكن تعيينها تلقائياً إلى وكيل"
|
||||
},
|
||||
"ASSIGNMENT": {
|
||||
"TITLE": "تعيين المحادثة",
|
||||
"DESCRIPTION": "تعيين المحادثات الواردة تلقائياً إلى الوكلاء المتاحين استناداً إلى سياسات التعيين",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "تمكين تعيين المحادثة تلقائياً",
|
||||
"DEFAULT_RULES_TITLE": "قواعد التعيين الافتراضية",
|
||||
"DEFAULT_RULES_DESCRIPTION": "استخدام سلوك التعيين الافتراضي لجميع المحادثات",
|
||||
"DEFAULT_RULE_1": "المحادثات التي تم إنشاؤها أولاً",
|
||||
"DEFAULT_RULE_2": "توزيع الجدولة الدائرية (Round robin)",
|
||||
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||
"USING_POLICY": "استخدام سياسة التعيين المخصصة لهذه القناة",
|
||||
"CUSTOMIZE_POLICY": "التخصيص وفقًا لسياسة التعيين",
|
||||
"DELETE_POLICY": "حذف سياسة",
|
||||
"POLICY_LABEL": "سياسة التعيين",
|
||||
"ASSIGNMENT_ORDER_LABEL": "ترتيب التعيين",
|
||||
"ASSIGNMENT_METHOD_LABEL": "طريقة التعيين",
|
||||
"POLICY_STATUS": {
|
||||
"ACTIVE": "مفعل",
|
||||
"INACTIVE": "غير نشط"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"EARLIEST_CREATED": "تم إنشاؤها في وقت سابق",
|
||||
"LONGEST_WAITING": "أطول انتظار"
|
||||
},
|
||||
"METHOD": {
|
||||
"ROUND_ROBIN": "Round robin",
|
||||
"BALANCED": "تعيين متوازن"
|
||||
},
|
||||
"UPGRADE_PROMPT": "سياسة التعيين المخصصة متاحى في الخطة (Business) ",
|
||||
"UPGRADE_TO_BUSINESS": "الترقية إلى (Business)",
|
||||
"DEFAULT_POLICY_LINKED": "السياسة الافتراضية المرتبطة",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "ربط سياسة تعيين مخصصة لتخصيص كيفية تعيين المحادثات إلى الوكلاء في هذه القناه",
|
||||
"LINK_EXISTING_POLICY": "ربط السياسة الحالية",
|
||||
"CREATE_NEW_POLICY": "إنشاء سياسة جديدة",
|
||||
"NO_POLICIES": "لم يتم العثور على سياسات التعيين",
|
||||
"VIEW_ALL_POLICIES": "عرض جميع السياسات",
|
||||
"CURRENT_BEHAVIOR": "حاليا يستخدم سلوك التعيين الافتراضي:",
|
||||
"LINK_SUCCESS": "تم ربط سياسة التعيين بنجاح",
|
||||
"LINK_ERROR": "فشل في ربط سياسة التعيين"
|
||||
},
|
||||
"ASSIGNMENT_POLICY": {
|
||||
"DELETE_CONFIRM_TITLE": "حذف سياسة التعيين؟",
|
||||
"DELETE_CONFIRM_MESSAGE": "هل أنت متأكد من أنك تريد إزالة سياسة التعيين هذه من صندوق الوارد هذا؟ صندوق الوارد سوف يعود إلى قواعد التعيين الافتراضية.",
|
||||
"CANCEL": "إلغاء",
|
||||
"CONFIRM_DELETE": "حذف",
|
||||
"DELETE_SUCCESS": "تمت إزالة سياسة التعيين بنجاح",
|
||||
"DELETE_ERROR": "فشل في إزالة سياسة التعيين"
|
||||
},
|
||||
"FACEBOOK_REAUTHORIZE": {
|
||||
"TITLE": "إعادة التصريح",
|
||||
"SUBTITLE": "انتهت صلاحية اتصال الفيسبوك الخاص بك، يرجى إعادة الاتصال بصفحة الفيسبوك الخاصة بك لمواصلة الخدمات",
|
||||
@@ -809,34 +886,48 @@
|
||||
"PLACEHOLDER": "Please enter a message to show users with the form"
|
||||
},
|
||||
"BUTTON_TEXT": {
|
||||
"LABEL": "Button text",
|
||||
"PLACEHOLDER": "Please rate us"
|
||||
"LABEL": "نص الزر",
|
||||
"PLACEHOLDER": "يرجى تقييمنا"
|
||||
},
|
||||
"LANGUAGE": {
|
||||
"LABEL": "اللغة",
|
||||
"PLACEHOLDER": "Select template language"
|
||||
"PLACEHOLDER": "اختر لغة القالب"
|
||||
},
|
||||
"MESSAGE_PREVIEW": {
|
||||
"LABEL": "Message preview",
|
||||
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
|
||||
"LABEL": "معاينة الرسالة",
|
||||
"TOOLTIP": "قد يختلف هذا قليلاً عند تقديمه على منصة WhatsApp."
|
||||
},
|
||||
"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."
|
||||
"APPROVED": "معتمد بواسطة WhatsApp",
|
||||
"PENDING": "في انتظار موافقة WhatsApp",
|
||||
"REJECTED": "Meta رفضت القالب",
|
||||
"DEFAULT": "يحتاج موافقة WhatsApp",
|
||||
"NOT_FOUND": "هذا القالب غير موجود في منصة ميتا."
|
||||
},
|
||||
"TEMPLATE_CREATION": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
|
||||
"ERROR_MESSAGE": "Failed to create WhatsApp template"
|
||||
"SUCCESS_MESSAGE": "تم إنشاء قالب WhatsApp بنجاح وإرساله للموافقة عليه",
|
||||
"ERROR_MESSAGE": "فشل إنشاء قالب WhatsApp"
|
||||
},
|
||||
"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",
|
||||
"TITLE": "تعديل تفاصيل الاستبيان",
|
||||
"DESCRIPTION": "سوف نقوم بحذف القالب السابق ونقوم بإنشاء قالب جديد حيث سيتم إرساله مرة أخرى للاعتماد من قبل Whatsapp",
|
||||
"CONFIRM": "إنشاء قالب جديد",
|
||||
"CANCEL": "العودة للخلف"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -848,7 +939,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.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are 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."
|
||||
@@ -864,9 +955,11 @@
|
||||
"UNAVAILABLE_MESSAGE_LABEL": "رسالة غير متاح للزائرين",
|
||||
"TOGGLE_HELP": "تمكين توفر العمل سيظهر الساعات المتاحة على أداة الدردشة المباشرة حتى لو كان جميع الوكلاء غير متصلين بالإنترنت. خارج الساعات المتاحة يمكن تحذير الزوار برسالة ونموذج ما قبل الدردشة.",
|
||||
"DAY": {
|
||||
"DAY": "اليوم",
|
||||
"AVAILABILITY": "التوفر",
|
||||
"HOURS": "Hours",
|
||||
"ENABLE": "تمكين التوفر لهذا اليوم",
|
||||
"UNAVAILABLE": "غير متوفر",
|
||||
"HOURS": "ساعات",
|
||||
"VALIDATION_ERROR": "يجب أن يكون وقت البدء قبل وقت الإغلاق.",
|
||||
"CHOOSE": "اختر"
|
||||
},
|
||||
@@ -973,11 +1066,12 @@
|
||||
"IN_A_DAY": "خلال يوم"
|
||||
},
|
||||
"WIDGET_COLOR_LABEL": "لون صندوق الدردشة",
|
||||
"WIDGET_BUBBLE_POSITION_LABEL": "موقع شعار اللايف شات",
|
||||
"WIDGET_BUBBLE_TYPE_LABEL": "شكل عرض اللايف شات",
|
||||
"WIDGET_BUBBLE": "Bubble",
|
||||
"WIDGET_BUBBLE_POSITION_LABEL": "Position:",
|
||||
"WIDGET_BUBBLE_TYPE_LABEL": "النوع:",
|
||||
"WIDGET_BUBBLE_LAUNCHER_TITLE": {
|
||||
"DEFAULT": "تحدث الينا",
|
||||
"LABEL": "عنوان ايقونة اللايف شات",
|
||||
"LABEL": "Launcher Title",
|
||||
"PLACE_HOLDER": "تحدث الينا"
|
||||
},
|
||||
"UPDATE": {
|
||||
@@ -1002,7 +1096,7 @@
|
||||
},
|
||||
"WIDGET_SCREEN": {
|
||||
"DEFAULT": "افتراضي",
|
||||
"CHAT": "محادثة"
|
||||
"CHAT": "Chat mode"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "عادة نقوم بالرد خلال بضع دقائق",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"FETCHING": "جلب التكاملات",
|
||||
"NO_HOOK_CONFIGURED": "لا يوجد {integrationId} تكاملات مكونة في هذا الحساب.",
|
||||
"HEADER": "التطبيقات",
|
||||
"COUNT": "{n} integration | {n} integrations",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"NO_RESULTS": "No results found matching your search",
|
||||
"STATUS": {
|
||||
"ENABLED": "مفعل",
|
||||
"DISABLED": "معطّل"
|
||||
@@ -31,6 +34,7 @@
|
||||
"LIST": {
|
||||
"FETCHING": "جلب روابط التكامل",
|
||||
"INBOX": "صندوق الوارد",
|
||||
"ACTIONS": "الإجراءات",
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "حذف"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"INTEGRATION_SETTINGS": {
|
||||
"SHOPIFY": {
|
||||
"HEADER": "Shopify",
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Shopify Integration",
|
||||
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
|
||||
@@ -19,6 +20,8 @@
|
||||
"DESCRIPTION": "Chatwoot تتكامل مع أدوات وخدمات متعددة لتحسين كفاءة فريقك. استكشف القائمة أدناه لتكوين تطبيقاتك المفضلة.",
|
||||
"LEARN_MORE": "معرفة المزيد عن التكاملات",
|
||||
"LOADING": "جاري جلب التكاملات",
|
||||
"SEARCH_PLACEHOLDER": "Search integrations...",
|
||||
"NO_RESULTS": "No integrations found matching your search",
|
||||
"CAPTAIN": {
|
||||
"DISABLED": "لم يتم تمكين الكابتن على حسابك.",
|
||||
"CLICK_HERE_TO_CONFIGURE": "انقر هنا للتهيئة",
|
||||
@@ -28,6 +31,17 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "الأحداث المشتركة",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||
"TOGGLE": "Toggle secret visibility",
|
||||
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
|
||||
"DONE": "Done"
|
||||
},
|
||||
"COUNT": "{n} webhook | {n} webhooks",
|
||||
"SEARCH_PLACEHOLDER": "Search webhooks...",
|
||||
"NO_RESULTS": "No webhooks found matching your search",
|
||||
"FORM": {
|
||||
"CANCEL": "إلغاء",
|
||||
"DESC": "أحداث Webhook توفر لك معلومات في الوقت الحقيقي حول ما يحدث في حساب Chatwoot الخاص بك. الرجاء إدخال عنوان URL صالح لتكوين callback.",
|
||||
@@ -104,6 +118,7 @@
|
||||
}
|
||||
},
|
||||
"SLACK": {
|
||||
"HEADER": "Slack",
|
||||
"DELETE": "حذف",
|
||||
"DELETE_CONFIRMATION": {
|
||||
"TITLE": "Delete the integration",
|
||||
@@ -145,7 +160,29 @@
|
||||
"EXPAND": "Expand",
|
||||
"MAKE_FRIENDLY": "Change message tone to friendly",
|
||||
"MAKE_FORMAL": "Use formal tone",
|
||||
"SIMPLIFY": "تبسيط"
|
||||
"SIMPLIFY": "تبسيط",
|
||||
"CONFIDENT": "استخدام نبرة لطيفة",
|
||||
"PROFESSIONAL": "استخدام نبرة احترافية",
|
||||
"CASUAL": "استخدم نبرة عادية",
|
||||
"STRAIGHTFORWARD": "استخدام نبرة مباشرة"
|
||||
},
|
||||
"REPLY_OPTIONS": {
|
||||
"IMPROVE_REPLY": "تحسين الرد",
|
||||
"IMPROVE_REPLY_SELECTION": "تحسين عملية الاختيار",
|
||||
"CHANGE_TONE": {
|
||||
"TITLE": "تغيير النبرة",
|
||||
"OPTIONS": {
|
||||
"PROFESSIONAL": "مهني",
|
||||
"CASUAL": "عادية",
|
||||
"STRAIGHTFORWARD": "مباشر",
|
||||
"CONFIDENT": "لطيفة",
|
||||
"FRIENDLY": "ودي"
|
||||
}
|
||||
},
|
||||
"GRAMMAR": "أصلاح القواعد النحوية والإملائية",
|
||||
"SUGGESTION": "اقترح رداً",
|
||||
"SUMMARIZE": "تلخيص المحادثة",
|
||||
"ASK_COPILOT": "إسأل المساعد"
|
||||
},
|
||||
"ASSISTANCE_MODAL": {
|
||||
"DRAFT_TITLE": "Draft content",
|
||||
@@ -201,12 +238,16 @@
|
||||
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
|
||||
"DESCRIPTION": "تسمح تطبيقات لوحة التحكم للمنظمات بتضمين تطبيق داخل لوحة التحكم لتوفير السياق لوكلاء دعم العملاء. هذه الميزة تسمح لك بإنشاء تطبيق بشكل مستقل وإدراج لتوفير معلومات المستخدم أو طلباتهم أو سجل الدفع السابق.",
|
||||
"LEARN_MORE": "معرفة المزيد حول تطبيقات لوحة التحكم",
|
||||
"COUNT": "{n} dashboard app | {n} dashboard apps",
|
||||
"SEARCH_PLACEHOLDER": "Search dashboard apps...",
|
||||
"NO_RESULTS": "No dashboard apps found matching your search",
|
||||
"LIST": {
|
||||
"404": "لا توجد تطبيقات لوحة التحكم التي تم تكوينها على هذا الحساب حتى الآن",
|
||||
"LOADING": "جلب تطبيقات لوحة التحكم...",
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "الاسم",
|
||||
"ENDPOINT": "نقطة الوصول"
|
||||
"ENDPOINT": "نقطة الوصول",
|
||||
"ACTIONS": "الإجراءات"
|
||||
},
|
||||
"EDIT_TOOLTIP": "تعديل التطبيق",
|
||||
"DELETE_TOOLTIP": "حذف التطبيق"
|
||||
@@ -243,6 +284,7 @@
|
||||
}
|
||||
},
|
||||
"LINEAR": {
|
||||
"HEADER": "Linear",
|
||||
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
|
||||
"LOADING": "جلب مشاكل من Linear...",
|
||||
"LOADING_ERROR": "حدث خطأ أثناء جلب المشكلات من Linear، الرجاء المحاولة مرة أخرى",
|
||||
@@ -337,6 +379,7 @@
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"HEADER": "نوشن",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
@@ -406,6 +449,7 @@
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "ولا يتوفر الكابتن AI إلا في خطط المؤسسة.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
@@ -483,7 +527,8 @@
|
||||
"TITLE": "الخصائص",
|
||||
"ALLOW_CONVERSATION_FAQS": "Generate FAQs from resolved conversations",
|
||||
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
|
||||
"ALLOW_CITATIONS": "Include source citations in responses"
|
||||
"ALLOW_CITATIONS": "Include source citations in responses",
|
||||
"ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "جار جلب الوسوم",
|
||||
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
|
||||
"LEARN_MORE": "معرفة المزيد حول التسميات",
|
||||
"COUNT": "{n} label | {n} labels",
|
||||
"SEARCH_PLACEHOLDER": "ابحث عن تصنيفات...",
|
||||
"NO_RESULTS": "No labels found matching your search",
|
||||
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام",
|
||||
"LIST": {
|
||||
"404": "لا توجد وسوم متوفرة في هذا الحساب.",
|
||||
@@ -13,7 +16,8 @@
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "الاسم",
|
||||
"DESCRIPTION": "الوصف",
|
||||
"COLOR": "اللون"
|
||||
"COLOR": "اللون",
|
||||
"ACTION": "الإجراءات"
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user