Compare commits

..
Author SHA1 Message Date
Vishnu Narayanan 0ef3b3b5b5 fix: force build_contact and increment_view_count methods to use writing role
These methods are used by the widget to create a contact and helpcenter to
increment the view count. Since these methods are triggered by GET
request, Rails will use the reading role which will fail if the table
is readonly.
2024-05-24 17:31:35 +05:30
Vishnu NarayananandGitHub af084af774 Merge branch 'develop' into feat/add_read_replica_support 2024-05-16 08:41:13 +05:30
Vishnu Narayanan 5a682135f2 feat: configure auto db switching 2024-05-07 16:37:41 +10:00
Vishnu NarayananandGitHub a72dba1769 Merge branch 'develop' into feat/add_read_replica_support 2024-05-07 12:33:32 +10:00
Vishnu NarayananandGitHub cc653e072a Merge branch 'develop' into feat/add_read_replica_support 2024-05-03 14:39:38 +10:00
Vishnu Narayanan 7e64671a27 chore: add replica settings for test&dev 2024-05-03 14:37:37 +10:00
Vishnu Narayanan 43bca252f9 chore: revert db fallback initializer 2024-05-03 13:36:37 +10:00
Vishnu Narayanan 5dc74ff40f chore: debug gh action 2024-05-03 12:34:12 +10:00
Vishnu Narayanan 8dd735f3ea chore: debug gh action 2024-05-03 12:29:33 +10:00
Vishnu Narayanan ce69614662 Merge branch 'feat/add_read_replica_support' of github.com:chatwoot/chatwoot into feat/add_read_replica_support 2024-05-03 12:26:13 +10:00
Vishnu Narayanan 8a6b13fd85 chore: debug gh action 2024-05-03 12:25:47 +10:00
Vishnu NarayananandGitHub 2692dba5bd Merge branch 'develop' into feat/add_read_replica_support 2024-05-03 11:54:54 +10:00
Vishnu Narayanan 0095254e65 feat: handle if read_replica is not available 2024-05-02 18:59:42 +10:00
Vishnu Narayanan 3b34be5740 feat: add read replica support 2024-05-02 18:46:46 +10:00
413 changed files with 1250 additions and 4898 deletions
+13 -5
View File
@@ -1,9 +1,4 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
@@ -62,6 +57,13 @@ POSTGRES_PASSWORD=
RAILS_ENV=development
# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required.
# POSTGRES_STATEMENT_TIMEOUT=14s
# read replica configuration for production
# POSTGRES_HOST_RR=postgres_rr
# create a new user with readonly permissions
# POSTGRES_USERNAME_RR=postgres_readonly
# POSTGRES_PASSWORD_RR=
RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent
@@ -189,6 +191,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# SENTRY_DSN=
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
-1
View File
@@ -45,7 +45,6 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
+1 -1
View File
@@ -77,7 +77,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-active_storage', '>= 1.0.2'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
+57 -59
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
actioncable (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionmailbox (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.3)
actionpack (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionmailer (7.0.8.1)
actionpack (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activesupport (= 7.0.8.1)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.3)
actionview (= 7.0.8.3)
activesupport (= 7.0.8.3)
actionpack (7.0.8.1)
actionview (= 7.0.8.1)
activesupport (= 7.0.8.1)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.3)
actionpack (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
actiontext (7.0.8.1)
actionpack (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.3)
activesupport (= 7.0.8.3)
actionview (7.0.8.1)
activesupport (= 7.0.8.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.3)
activesupport (= 7.0.8.3)
activejob (7.0.8.1)
activesupport (= 7.0.8.1)
globalid (>= 0.3.6)
activemodel (7.0.8.3)
activesupport (= 7.0.8.3)
activerecord (7.0.8.3)
activemodel (= 7.0.8.3)
activesupport (= 7.0.8.3)
activemodel (7.0.8.1)
activesupport (= 7.0.8.1)
activerecord (7.0.8.1)
activemodel (= 7.0.8.1)
activesupport (= 7.0.8.1)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activesupport (= 7.0.8.3)
activestorage (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activesupport (= 7.0.8.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.3)
activesupport (7.0.8.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.3)
administrate-field-active_storage (1.0.2)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -461,7 +461,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.6)
minitest (5.23.0)
minitest (5.22.3)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -569,20 +569,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.3)
actioncable (= 7.0.8.3)
actionmailbox (= 7.0.8.3)
actionmailer (= 7.0.8.3)
actionpack (= 7.0.8.3)
actiontext (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activemodel (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
rails (7.0.8.1)
actioncable (= 7.0.8.1)
actionmailbox (= 7.0.8.1)
actionmailer (= 7.0.8.1)
actionpack (= 7.0.8.1)
actiontext (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activemodel (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
bundler (>= 1.15.0)
railties (= 7.0.8.3)
railties (= 7.0.8.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -590,9 +590,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
railties (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -628,8 +628,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.2.8)
strscan (>= 3.0.9)
rexml (3.2.5)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
@@ -759,7 +758,6 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
@@ -839,7 +837,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-active_storage (>= 1.0.2)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::BaseReportBuilder
pattr_initialize :account, :params
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end
@@ -1,21 +0,0 @@
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def 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|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,46 +0,0 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def group_by
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
end
@@ -1,71 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -1,93 +0,0 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
def teams
teams = linear_processor_service.teams
if teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams[:data], status: :ok
end
end
def team_entities
team_id = permitted_params[:team_id]
team_entities = linear_processor_service.team_entities(team_id)
if team_entities[:error]
render json: { error: team_entities[:error] }, status: :unprocessable_entity
else
render json: team_entities[:data], status: :ok
end
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def linked_issues
issues = linear_processor_service.linked_issues(conversation_link)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
def search_issue
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
term = params[:q]
issues = linear_processor_service.search_issue(term)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
private
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
end
def fetch_conversation
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
end
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end
@@ -5,17 +5,19 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.timeseries
builder = V2::ReportBuilder.new(Current.account, report_params)
data = builder.build
render json: data
end
def summary
render json: build_summary(:summary)
render json: summary_metrics
end
def bot_summary
render json: build_summary(:bot_summary)
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
render json: summary
end
def agents
@@ -124,11 +126,10 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
}
end
def build_summary(method)
builder = V2::Reports::Conversations::MetricBuilder
current_summary = builder.new(Current.account, current_summary_params).send(method)
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
current_summary.merge(previous: previous_summary)
def summary_metrics
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
end
def conversation_metrics
@@ -2,10 +2,7 @@ module MicrosoftConcern
extend ActiveSupport::Concern
def microsoft_client
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret,
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
{
site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+1 -6
View File
@@ -3,7 +3,6 @@ class DashboardController < ActionController::Base
before_action :set_application_pack
before_action :set_global_config
before_action :set_dashboard_scripts
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
@@ -36,10 +35,6 @@ class DashboardController < ActionController::Base
).merge(app_config)
end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end
@@ -63,7 +58,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
}
end
@@ -7,14 +7,9 @@ class Microsoft::CallbacksController < ApplicationController
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox, already_exists = find_or_create_inbox
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
if already_exists
redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -45,17 +40,9 @@ class Microsoft::CallbacksController < ApplicationController
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
channel_email.inbox
end
# Fallback name, for when name field is missing from users_data
+1 -1
View File
@@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController
end
def microsoft_indentity
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
end
end
@@ -35,8 +35,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
+4 -2
View File
@@ -47,8 +47,10 @@ class WidgetsController < ActionController::Base
def build_contact
return if @contact.present?
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
ActiveRecord::Base.connected_to(role: :writing) do
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
end
end
def ensure_account_is_active
-19
View File
@@ -1,19 +0,0 @@
module TimezoneHelper
# 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
#
# https://github.com/rails/rails/pull/22243
# https://github.com/rails/rails/issues/21501
# https://github.com/rails/rails/issues/7297
def timezone_name_from_offset(offset)
return 'UTC' if offset.blank?
offset_in_seconds = offset.to_f * 3600
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
zone.now.utc_offset == offset_in_seconds
end
return matching_zone.name if matching_zone
end
end
@@ -1,47 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId, title) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
title: title,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();
@@ -29,7 +29,6 @@ class OpenAIAPI extends ApiClient {
'summarize',
'reply_suggestion',
'label_suggestion',
'summary_with_title',
];
/**
@@ -1,202 +0,0 @@
import LinearAPIClient from '../../integrations/linear';
import ApiClient from '../../ApiClient';
describe('#linearAPI', () => {
it('creates correct instance', () => {
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
expect(LinearAPIClient).toHaveProperty('getTeams');
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
expect(LinearAPIClient).toHaveProperty('createIssue');
expect(LinearAPIClient).toHaveProperty('link_issue');
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
expect(LinearAPIClient).toHaveProperty('searchIssues');
});
describe('getTeams', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeams();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/teams'
);
});
});
describe('getTeamEntities', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeamEntities(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/team_entities?team_id=1'
);
});
});
describe('createIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.link_issue(1, 2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 2,
conversation_id: 1,
}
);
});
});
describe('getLinkedIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getLinkedIssue(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
);
});
});
describe('unlinkIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
}
);
});
});
describe('searchIssues', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.searchIssues('query');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/search_issue?q=query'
);
});
});
});
@@ -7,17 +7,79 @@
]"
>
<slot />
<chat-list-header
:page-title="pageTitle"
:has-applied-filters="hasAppliedFilters"
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
@basic-filter-change="onBasicFilterChange"
/>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
<add-custom-views
v-if="showAddFoldersModal"
@@ -111,15 +173,6 @@
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
@@ -127,8 +180,8 @@
import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
@@ -152,15 +205,10 @@ import {
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getUnixTime } from 'date-fns';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
ChatListHeader,
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
@@ -168,9 +216,9 @@ export default {
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
CustomSnoozeModal,
},
mixins: [
timeMixin,
@@ -247,7 +295,6 @@ export default {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
showCustomSnoozeModal: false,
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
@@ -282,13 +329,12 @@ export default {
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
selectedConversations: 'bulkActions/getSelectedConversationIds',
contextMenuChatId: 'getContextMenuChatId',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
},
hasActiveFolders() {
return Boolean(this.activeFolder && this.foldersId !== 0);
return this.activeFolder && this.foldersId !== 0;
},
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
@@ -512,11 +558,6 @@ export default {
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
updateVirtualListProps(key, value) {
@@ -987,49 +1028,12 @@ export default {
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item)?.status === status;
return this.$store.getters.getConversationById(item).status === status;
});
},
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and then the custom snooze modal is open
// Then if the custom snooze modal is closed and set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
},
};
</script>
@@ -1,115 +0,0 @@
<script setup>
import { computed } from 'vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
});
const emits = defineEmits([
'add-folders',
'delete-folders',
'reset-filters',
'basic-filter-change',
'filters-modal',
]);
const onBasicFilterChange = (value, type) => {
emits('basic-filter-change', value, type);
};
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
</script>
<template>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emits('add-folders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emits('reset-filters')"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emits('filters-modal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emits('delete-folders')"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emits('filters-modal')"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
</template>
@@ -27,7 +27,7 @@
/>
</span>
<woot-button
v-if="showActions && value"
v-if="showCopyAndDeleteButton"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
@@ -90,7 +90,7 @@
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showActions && value"
v-if="showCopyAndDeleteButton"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
@@ -100,7 +100,7 @@
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-if="showEditButton"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
@@ -174,6 +174,12 @@ export default {
};
},
computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
@@ -99,9 +99,7 @@ export default {
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
if (this.closeOnBackdropClick) {
this.onClose();
}
this.onClose();
}
},
},
@@ -1,5 +1,5 @@
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div class="resolve-actions relative flex items-center justify-end">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -73,13 +73,25 @@
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -87,12 +99,14 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SNOOZE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
@@ -101,6 +115,7 @@ export default {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
};
},
computed: {
@@ -128,10 +143,12 @@ export default {
},
},
mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
@@ -184,6 +201,28 @@ export default {
// error
}
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
@@ -7,7 +7,7 @@
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="px-8 py-4">
<div class="px-8 pt-4 pb-8">
<div
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
@@ -45,10 +45,10 @@
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="flex justify-end items-center px-8 pb-8 pt-4 gap-2"
class="flex justify-end items-center p-8 gap-2"
>
<button
class="button success large expanded nice w-full"
class="button success large expanded nice"
@click="$emit('show-create-account-modal')"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
@@ -1,15 +0,0 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -103,7 +103,6 @@
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@@ -71,10 +71,6 @@
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -93,8 +89,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
export default {
components: {
@@ -103,7 +97,6 @@ export default {
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
@@ -128,9 +121,6 @@ export default {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
@@ -188,17 +178,6 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
methods: {
@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
{{ $t('SLA.EVENTS.TITLE') }}
@@ -16,7 +16,7 @@
/>
</template>
<menu-item
v-if="showSnooze"
v-if="show(snoozeOption.key)"
:option="snoozeOption"
variant="icon"
@click="snoozeConversation()"
@@ -86,10 +86,6 @@ export default {
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
default: null,
},
status: {
type: String,
default: '',
@@ -209,10 +205,6 @@ export default {
...this.filteredAgentOnAvailability,
];
},
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -221,8 +213,7 @@ export default {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
snoozeConversation() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
@@ -1,272 +0,0 @@
<template>
<div @submit.prevent="onSubmit">
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<div class="editor-wrap">
<label>
{{
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL')
}}
</label>
<div>
<woot-message-editor
v-model="formState.description"
class="message-editor"
:style="{ ...inputStyles, padding: '8px 12px' }"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</div>
</div>
<label :class="{ error: v$.teamId.$error }">
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
<select
v-model="formState.teamId"
:style="inputStyles"
@change="onChangeTeam"
>
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.teamId.$error" class="message">
{{ teamError }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
<select v-model="formState.assigneeId" :style="inputStyles">
<option v-for="item in assignees" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
<select v-model="formState.labelId" :style="inputStyles">
<option v-for="item in labels" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
<select v-model="formState.priority" :style="inputStyles">
<option v-for="item in priorities" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
<select v-model="formState.projectId" :style="inputStyles">
<option v-for="item in projects" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
<select v-model="formState.stateId" :style="inputStyles">
<option v-for="item in statuses" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import { inject } from 'vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChangeTeam = event => {
formState.teamId = event.target.value;
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
getTeamEntities();
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(() => {
getTeams();
if (inject('suggestedTitle')) {
formState.title = inject('suggestedTitle');
}
if (inject('suggestedSummary')) {
formState.description = inject('suggestedSummary');
}
});
</script>
@@ -1,92 +0,0 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<create-issue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<link-issue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const emits = defineEmits(['close']);
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emits('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
@@ -1,123 +0,0 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlink-issue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<issue-header
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-ash-900">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-ash-800 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-ash-900">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-ash-800">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-row justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-0.5">
<woot-button
variant="clear"
color-scheme="secondary"
class="h-[24px]"
:is-loading="isUnlinking"
@click="unlinkIssue"
>
<fluent-icon
v-if="!isUnlinking"
icon="unlink"
size="12"
type="outline"
icon-lib="lucide"
/>
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const isUnlinking = inject('isUnlinking');
const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => {
emit('unlink-issue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
@@ -1,143 +0,0 @@
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@click="onSelectIssue"
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking"
@click.prevent="linkIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emits = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emits('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>
@@ -1,176 +0,0 @@
<template>
<div class="relative" :class="{ group: linkedIssue }">
<woot-button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
variant="clear"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }}
</span>
</woot-button>
<issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
:show.sync="shouldShowPopup"
:on-close="closePopup"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<create-or-link-issue
:conversation="conversation"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import OpenAPI from 'dashboard/api/integrations/openapi';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
defineComponent({
name: 'Linear',
});
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const suggestedTitle = ref('');
const suggestedSummary = ref('');
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
provide('suggestedTitle', suggestedTitle);
provide('suggestedSummary', suggestedSummary);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(() =>
getters.getConversationById.value(props.conversationId)
);
const appIntegrations = getters['integrations/getAppIntegrations'];
const aiIntegration = computed(() => {
return appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0];
});
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
const tooltipText = computed(() => {
return linkedIssue.value === null
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const suggestTitleAndSummary = async () => {
try {
const response = await OpenAPI.processEvent({
type: 'summary_with_title',
hookId: aiIntegration.value.id,
conversationId: props.conversationId,
});
const { message } = response.data;
const messageObject = JSON.parse(message);
const { title = '', description = '' } = messageObject;
suggestedTitle.value = title;
suggestedSummary.value = description;
} catch (error) {
// Since this is a non-critical operation, we don't want to show an alert to the user
}
};
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
}
};
const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
} finally {
isUnlinking.value = false;
}
};
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
loadLinkedIssue();
if (isAIIntegrationEnabled.value) {
suggestTitleAndSummary();
}
});
</script>
@@ -1,10 +0,0 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};
@@ -1,16 +1,9 @@
<template>
<div class="phone-input--wrap relative">
<div
class="flex items-center dark:bg-slate-900 justify-start rounded-md border border-solid"
:class="
error
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
: 'mb-4 border-slate-200 dark:border-slate-600'
"
>
<div class="phone-input" :class="{ 'has-error': error }">
<div
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@click.prevent="toggleCountryDropdown"
@click="toggleCountryDropdown"
>
<h5 v-if="activeCountry" class="mb-0">
{{ activeCountry.emoji }}
@@ -20,15 +13,14 @@
</div>
<span
v-if="activeDialCode"
class="flex bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
class="flex bg-white dark:bg-slate-900 font-medium text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
>
{{ activeDialCode }}
</span>
<input
ref="phoneNumberInput"
:value="phoneNumber"
type="tel"
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
class="phone-input--field"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@@ -36,36 +28,24 @@
@blur="onBlur"
/>
</div>
<div
v-if="showDropdown"
ref="dropdown"
v-on-clickaway="onOutsideClick"
tabindex="0"
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
@keydown.prevent.up="moveUp"
@keydown.prevent.down="moveDown"
@keydown.prevent.enter="
onSelectCountry(filteredCountriesBySearch[selectedIndex])
"
>
<div class="top-0 sticky bg-white dark:bg-slate-900 p-1">
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-200 dark:!border-slate-600"
@input="onSearchCountry"
class="dropdown-search"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700"
class="country-dropdown--item"
:class="{
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
active: country.id === activeCountryCode,
focus: index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
@@ -94,8 +74,10 @@
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [keyboardEventListenerMixins],
props: {
value: {
type: [String, Number],
@@ -120,6 +102,15 @@ export default {
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
@@ -129,20 +120,6 @@ export default {
};
},
computed: {
countries() {
return [
{
name: this.dropdownFirstItemName,
dial_code: '',
emoji: '',
id: '',
},
...countries,
];
},
dropdownFirstItemName() {
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
},
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
@@ -177,8 +154,12 @@ export default {
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
@@ -196,14 +177,28 @@ export default {
onBlur(e) {
this.$emit('blur', e.target.value);
},
onSearchCountry() {
// Reset selected index to 0
this.selectedIndex = 0;
dropdownItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
if (!this.showDropdown) return -1;
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
moveUp() {
if (!this.showDropdown) return;
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.scrollToSelected();
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
},
moveDown() {
if (!this.showDropdown) return;
@@ -211,27 +206,14 @@ export default {
this.selectedIndex + 1,
this.filteredCountriesBySearch.length - 1
);
this.scrollToSelected();
},
scrollToSelected() {
this.$nextTick(() => {
const dropdown = this.$refs.dropdown;
const selectedItem = this.$refs.dropdownItem[this.selectedIndex];
const dropdownSearchbarHeight = 40;
if (selectedItem) {
const selectedItemTop = selectedItem.offsetTop;
dropdown.scrollTop = selectedItemTop - dropdownSearchbarHeight;
}
});
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
},
onSelectCountry(country) {
if (!country || !this.showDropdown) return;
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
this.$refs.phoneNumberInput.focus();
},
setActiveCountry() {
const { phoneNumber } = this;
@@ -242,6 +224,33 @@ export default {
this.activeDialCode = number.countryCallingCode;
}
},
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
e.preventDefault();
this.moveUp();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.moveDown();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
e.preventDefault();
this.onSelectCountry(
this.filteredCountriesBySearch[this.selectedIndex]
);
},
allowOnFocusedInput: true,
},
};
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
@@ -258,3 +267,46 @@ export default {
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
.phone-input {
@apply flex items-center dark:bg-slate-900 justify-start mb-4 rounded-md border border-solid border-slate-200 dark:border-slate-600;
&.has-error {
@apply border border-solid border-red-400 dark:border-red-400;
}
}
.phone-input--field {
@apply mb-0 rounded-tl-none rounded-bl-none border-0 w-full dark:bg-slate-900 text-base px-1.5;
&::placeholder {
@apply font-normal;
}
}
.country-dropdown {
@apply z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900;
.dropdown-search--wrap {
@apply top-0 sticky bg-white dark:bg-slate-900 p-1;
.dropdown-search {
@apply h-8 mb-0 text-sm border border-solid border-slate-200 dark:border-slate-600;
}
}
.country-dropdown--item {
@apply flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700;
&.active {
@apply bg-slate-50 dark:bg-slate-700;
}
&.focus {
@apply bg-slate-25 dark:bg-slate-800;
}
}
}
}
</style>
-1
View File
@@ -30,5 +30,4 @@ export const FEATURE_FLAGS = {
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
INBOUND_EMAILS: 'inbound_emails',
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
};
@@ -52,22 +52,8 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
return null;
};
export const isAConversationRoute = (
routeName,
includeBase = false,
includeExtended = true
) => {
const baseRoutes = [
'home',
'conversation_mentions',
'conversation_unattended',
'inbox_dashboard',
'label_conversations',
'team_conversations',
'folder_conversations',
'conversation_participating',
];
const extendedRoutes = [
export const isAConversationRoute = routeName =>
[
'inbox_conversation',
'conversation_through_mentions',
'conversation_through_unattended',
@@ -76,15 +62,7 @@ export const isAConversationRoute = (
'conversations_through_team',
'conversations_through_folders',
'conversation_through_participating',
];
const routes = [
...(includeBase ? baseRoutes : []),
...(includeExtended ? extendedRoutes : []),
];
return routes.includes(routeName);
};
].includes(routeName);
export const getConversationDashboardRoute = routeName => {
switch (routeName) {
@@ -106,51 +106,6 @@ describe('isAConversationRoute', () => {
expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false);
});
it('returns true if base conversation route name is provided and includeBase is true', () => {
expect(isAConversationRoute('home', true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true)).toBe(true);
expect(isAConversationRoute('conversation_unattended', true)).toBe(true);
expect(isAConversationRoute('inbox_dashboard', true)).toBe(true);
expect(isAConversationRoute('label_conversations', true)).toBe(true);
expect(isAConversationRoute('team_conversations', true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true)).toBe(true);
});
it('returns false if base conversation route name is provided and includeBase is false', () => {
expect(isAConversationRoute('home', false)).toBe(false);
expect(isAConversationRoute('conversation_mentions', false)).toBe(false);
expect(isAConversationRoute('conversation_unattended', false)).toBe(false);
expect(isAConversationRoute('inbox_dashboard', false)).toBe(false);
expect(isAConversationRoute('label_conversations', false)).toBe(false);
expect(isAConversationRoute('team_conversations', false)).toBe(false);
expect(isAConversationRoute('folder_conversations', false)).toBe(false);
expect(isAConversationRoute('conversation_participating', false)).toBe(
false
);
});
it('returns true if base conversation route name is provided and includeBase and includeExtended is true', () => {
expect(isAConversationRoute('home', true, true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true, true)).toBe(
true
);
expect(isAConversationRoute('conversation_unattended', true, true)).toBe(
true
);
expect(isAConversationRoute('inbox_dashboard', true, true)).toBe(true);
expect(isAConversationRoute('label_conversations', true, true)).toBe(true);
expect(isAConversationRoute('team_conversations', true, true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true, true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true, true)).toBe(
true
);
});
it('returns false if base conversation route name is not provided', () => {
expect(isAConversationRoute('')).toBe(false);
});
});
describe('getConversationDashboardRoute', () => {
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -107,7 +107,6 @@
"GENERAL": "General",
"REPORTS": "Reports",
"CONVERSATION": "Conversation",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Change Assignee",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Change Team",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "جاري تحميل الوكلاء",
"UPDATE": {
"CHANGE_STATUS": "تغيير الحالة",
"SNOOZE_UNTIL": "غفوة",
"SNOOZE_UNTIL_NEXT_REPLY": "غفوة حتى الرد القادم.",
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "تصدير جهات الاتصال",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "نعم, قم بالتصدير",
"NO": "نعم, إلغاء"
}
@@ -107,7 +107,6 @@
"GENERAL": "عام",
"REPORTS": "التقارير",
"CONVERSATION": "المحادثات",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "تغيير المحال إليه",
"CHANGE_PRIORITY": "تغيير الأولوية",
"CHANGE_TEAM": "تغيير الفريق",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "يتم تحديث إعدادات الإشعارات بنجاح",
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -107,7 +107,6 @@
"GENERAL": "General",
"REPORTS": "Reports",
"CONVERSATION": "Разговор",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Change Assignee",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Change Team",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
@@ -1,17 +1,17 @@
{
"FILTER": {
"TITLE": "Filtre de converses",
"SUBTITLE": "Afegiu els vostres filtres a continuació i premeu 'Aplicar filtres' per eliminar el desordre del xat.",
"EDIT_CUSTOM_FILTER": "Edita la carpeta",
"CUSTOM_VIEWS_SUBTITLE": "Afegeix o elimina filtres i actualitza la teva carpeta.",
"ADD_NEW_FILTER": "Afegeix un filtre",
"FILTER_DELETE_ERROR": "Vaja, sembla que no podem desar res! Afegiu almenys un filtre per desar-lo.",
"SUBTITLE": "Add your filters below and hit 'Apply filters' to cut through the chat clutter.",
"EDIT_CUSTOM_FILTER": "Edit Folder",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your folder.",
"ADD_NEW_FILTER": "Add filter",
"FILTER_DELETE_ERROR": "Oops, looks like we can't save nothing! Please add at least one filter to save it.",
"SUBMIT_BUTTON_LABEL": "Aplicar filtres",
"UPDATE_BUTTON_LABEL": "Actualitza la carpeta",
"UPDATE_BUTTON_LABEL": "Update folder",
"CANCEL_BUTTON_LABEL": "Cancel·la",
"CLEAR_BUTTON_LABEL": "Esborra els filtres",
"FOLDER_LABEL": "Nom de la carpeta",
"FOLDER_QUERY_LABEL": "Consulta de carpeta",
"CLEAR_BUTTON_LABEL": "Clear filters",
"FOLDER_LABEL": "Folder Name",
"FOLDER_QUERY_LABEL": "Folder Query",
"EMPTY_VALUE_ERROR": "El valor és necessari.",
"TOOLTIP_LABEL": "Filtre de converses",
"QUERY_DROPDOWN_LABELS": {
@@ -27,8 +27,8 @@
"is_not_present": "No és present",
"is_greater_than": "És més gran que",
"is_less_than": "És més petit que",
"days_before": "És x dies abans",
"starts_with": "Comença amb"
"days_before": "Is x days before",
"starts_with": "Starts with"
},
"ATTRIBUTE_LABELS": {
"TRUE": "Cert",
@@ -36,66 +36,66 @@
},
"ATTRIBUTES": {
"STATUS": "Estat",
"ASSIGNEE_NAME": "Nom assignat",
"INBOX_NAME": "Nom de la safata d'entrada",
"TEAM_NAME": "Nom de l'equip",
"CONVERSATION_IDENTIFIER": "Identificador de conversa",
"CAMPAIGN_NAME": "Nom de la campanya",
"ASSIGNEE_NAME": "Assignee name",
"INBOX_NAME": "Inbox name",
"TEAM_NAME": "Team name",
"CONVERSATION_IDENTIFIER": "Conversation identifier",
"CAMPAIGN_NAME": "Campaign name",
"LABELS": "Etiquetes",
"BROWSER_LANGUAGE": "Idioma del navegador",
"PRIORITY": "Prioritat",
"COUNTRY_NAME": "Nom del país",
"REFERER_LINK": "Enllaç de referència",
"CUSTOM_ATTRIBUTE_LIST": "Llista",
"BROWSER_LANGUAGE": "Browser language",
"PRIORITY": "Priority",
"COUNTRY_NAME": "Country name",
"REFERER_LINK": "Referer link",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Llista",
"CUSTOM_ATTRIBUTE_NUMBER": "Número",
"CUSTOM_ATTRIBUTE_LINK": "Enllaç",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Casella de selecció",
"CREATED_AT": "Creat a",
"LAST_ACTIVITY": "Última activitat"
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity"
},
"GROUPS": {
"STANDARD_FILTERS": "Filtres estàndard",
"ADDITIONAL_FILTERS": "Filtres addicionals",
"CUSTOM_ATTRIBUTES": "Atributs personalitzats"
"STANDARD_FILTERS": "Standard filters",
"ADDITIONAL_FILTERS": "Additional filters",
"CUSTOM_ATTRIBUTES": "Custom attributes"
},
"CUSTOM_VIEWS": {
"ADD": {
"TITLE": "Vols desar aquest filtre?",
"LABEL": "Anomena aquest filtre",
"PLACEHOLDER": "Anomena el teu filtre per referir-lo més endavant.",
"ERROR_MESSAGE": "El nom és obligatori.",
"SAVE_BUTTON": "Desa el filtre",
"TITLE": "Do you want to save this filter?",
"LABEL": "Name this filter",
"PLACEHOLDER": "Name your filter to refer it later.",
"ERROR_MESSAGE": "Name is required.",
"SAVE_BUTTON": "Save filter",
"CANCEL_BUTTON": "Cancel·la",
"API_FOLDERS": {
"SUCCESS_MESSAGE": "Carpeta creada correctament.",
"ERROR_MESSAGE": "S'ha produït un error en crear la carpeta."
"SUCCESS_MESSAGE": "Folder created successfully.",
"ERROR_MESSAGE": "Error while creating folder."
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "Segment creat correctament.",
"ERROR_MESSAGE": "S'ha produït un error en crear el segment."
"SUCCESS_MESSAGE": "Segment created successfully.",
"ERROR_MESSAGE": "Error while creating segment."
}
},
"EDIT": {
"EDIT_BUTTON": "Editar la carpeta"
"EDIT_BUTTON": "Edit folder"
},
"DELETE": {
"DELETE_BUTTON": "Suprimir el filtre",
"DELETE_BUTTON": "Delete filter",
"MODAL": {
"CONFIRM": {
"TITLE": "Confirmar la supressió",
"MESSAGE": "Esteu segur que suprimiu el filtre ",
"YES": "Sí, esborra",
"NO": "No, guarda-ho"
"TITLE": "Confirm deletion",
"MESSAGE": "Are you sure to delete the filter ",
"YES": "Yes, delete",
"NO": "No, keep it"
}
},
"API_FOLDERS": {
"SUCCESS_MESSAGE": "La carpeta s'ha suprimit correctament.",
"ERROR_MESSAGE": "S'ha produït un error en suprimir la carpeta."
"SUCCESS_MESSAGE": "Folder deleted successfully.",
"ERROR_MESSAGE": "Error while deleting folder."
},
"API_SEGMENTS": {
"SUCCESS_MESSAGE": "El segment s'ha suprimit correctament.",
"ERROR_MESSAGE": "S'ha produït un error en suprimir el segment."
"SUCCESS_MESSAGE": "Segment deleted successfully.",
"ERROR_MESSAGE": "Error while deleting segment."
}
}
}
@@ -1,7 +1,7 @@
{
"AGENT_BOTS": {
"HEADER": "Bots",
"LOADING_EDITOR": "S'està carregant l'editor...",
"LOADING_EDITOR": "Loading editor...",
"HEADER_BTN_TXT": "Add bot configuration",
"SIDEBAR_TXT": "<p><b>Agent Bots</b> <p>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.</p> <p> You can manage your bots from this page or create new ones using the 'Add bot configuraton' button.</p> <p> Open the <a href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677497472-how-to-use-agent-bots\" target=\"blank\">Agent bots handbook</a> in another tab for a helping hand.</p>",
"CSML_BOT_EDITOR": {
@@ -3,7 +3,7 @@
"HEADER": "Agents",
"HEADER_BTN_TXT": "Afegir Agent",
"LOADING": "S'està recollint la llista d'agents",
"SIDEBAR_TXT": "<p><b>Agents</b></p> <p> Un <b>Agent</b> és membre del vostre equip datenció al client. </p><p> Els agents podran veure i respondre missatges dels teus usuaris. La llista mostra tots els agents que hi ha actualment al teu compte.</p><p> Fer clic a <b>Afegeix Agent</b> per afegir un agent nou. L´agent que afegeixes rebrà un correu electrònic amb un enllaç de confirmació per activar el seu compte, després del qual podrà accedir a Chatwoot i respondre als missatges. </p><p> L´accés a les funcions de Chatwoot es basa en els següents rols.</p><p> <b>Agent</b> - Els agents amb aquesta funció només poden accedir a les bústies d´entrada, als informes i a les converses. Poden assignar converses a altres agents o a ells mateixos i resoldre converses.</p><p> <b>Administrador</b> - L´administrador tindrà accés a totes les funcions de Chatwoot habilitades per al teu compte, inclosa la configuració, juntament amb tots els privilegis dels agents normals.</p>",
"SIDEBAR_TXT": "<p><b>Agents</b></p> <p> Un <b>Agent</b> és membre del vostre equip datenció al client. </p><p> Els agents podran veure i respondre missatges dels teus usuaris. La llista mostra tots els agents que hi ha actualment al teu compte.</p><p> Fer clic a <b>Afegeix Agent</b> per afegir un agent nou. Lagent que afegeixes rebrà un correu electrònic amb un enllaç de confirmació per activar el seu compte, després del qual podrà accedir a Chatwoot i respondre als missatges. </p><p> Laccés a les funcions de Chatwoot es basa en els següents rols.</p><p> <b>Agent</b> - Els agents amb aquesta funció només poden accedir a les bústies dentrada, als informes i a les converses. Poden assignar converses a altres agents o a ells mateixos i resoldre converses.</p><p> <b>Administrador</b> - L'administrador tindrà accés a totes les funcions de Chatwoot habilitades per al teu compte, inclosa la configuració, juntament amb tots els privilegis dels agents normals.</p>",
"AGENT_TYPES": {
"ADMINISTRATOR": "Administrador/a",
"AGENT": "Agent"
@@ -31,7 +31,7 @@
"AGENT_TYPE": {
"LABEL": "Tipus d'Agent",
"PLACEHOLDER": "Selecciona un tipus",
"ERROR": "El rol és necessari"
"ERROR": "El tipus d'Agent és necessari"
},
"EMAIL": {
"LABEL": "Adreça de correu electrònic",
@@ -76,8 +76,8 @@
},
"AGENT_AVAILABILITY": {
"LABEL": "Disponibilitat",
"PLACEHOLDER": "Seleccioneu un estat de disponibilitat",
"ERROR": "Es requereix disponibilitat"
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Editar l'agent"
},
@@ -111,7 +111,7 @@
"PLACEHOLDER": {
"AGENT": "Cerca agents",
"TEAM": "Cerca equips",
"INPUT": "Cerca agents"
"INPUT": "Search for agents"
}
}
}
@@ -11,7 +11,7 @@
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
"TABLE_HEADER": [
"User",
"Temps",
"Action",
"Adreça IP"
]
},
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Snooze",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -98,7 +98,7 @@
"CREATED_AT": "Created at"
},
"BUTTONS": {
"ADD": "Afegir",
"ADD": "Add",
"EDIT": "Edita",
"DELETE": "Esborrar"
},
@@ -1,75 +1,75 @@
{
"CANNED_MGMT": {
"HEADER": "Respostes predeterminades",
"HEADER_BTN_TXT": "Afegir resposta predeterminada",
"LOADING": "S'estan recollint les respostes predeterminades...",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"SEARCH_404": "No hi ha cap resposta que coincideixi amb aquesta consulta.",
"SIDEBAR_TXT": "<p><b>Respostes predeterminades</b> </p><p> Les respostes predeterminades són plantilles de respostes escrites prèviament que us ajuden a respondre ràpidament a una conversa. Per inserir una resposta predefinida durant un xat, els agents poden escriure un codi curt precedit d'un caràcter '/'. </p><p> Pots gestionar les teves respostes predefinides des d'aquesta pàgina o crear-ne de noves mitjançant el botó \"Afegeix una resposta predefinida\".</p><p>Obre el <a target=\"_blank\" href=\"https: //www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Manual de respostes llaunes</a> en una altra pestanya per ajudar-te.</p><p>També, fes una ullada al nou <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank \">Biblioteca de respostes predeterminades</a>.</p>",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are pre-written reply templates that help you quickly respond to a conversation. To insert a canned response during a chat, agents can type a short code preceded by a '/' character. </p><p> You can manage your canned responses from this page or create new ones using the \"Add canned response\" button.</p><p>Open the <a target=\"_blank\" href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Canned Responses handbook</a> in another tab for a helping hand.</p><p>Also, check out the all-new <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank\">Canned Responses Library</a>.</p>",
"LIST": {
"404": "No hi ha respostes predeterminades disponibles en aquest compte.",
"TITLE": "Gestiona les respostes predeterminades",
"DESC": "Les respostes predeterminades són plantilles de resposta predefinides que es poden utilitzar per enviar ràpidament respostes a les converses.",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"TABLE_HEADER": [
"Codi curt",
"Short code",
"Contingut",
"Accions"
]
},
"ADD": {
"TITLE": "Afegir resposta predeterminada",
"DESC": "Les respostes predeterminades són plantilles de resposta predefinides que es poden utilitzar per enviar ràpidament respostes a les converses.",
"TITLE": "Add canned response",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"FORM": {
"SHORT_CODE": {
"LABEL": "Codi curt",
"PLACEHOLDER": "Introduïu un codi curt.",
"ERROR": "És necessari el codi curt."
"LABEL": "Short code",
"PLACEHOLDER": "Please enter a short code.",
"ERROR": "Short Code is required."
},
"CONTENT": {
"LABEL": "Missatge",
"PLACEHOLDER": "Escriviu el missatge que voleu desar com a plantilla per utilitzar-lo més tard.",
"ERROR": "El missatge és obligatori."
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
"ERROR": "Message is required."
},
"SUBMIT": "Envia"
},
"API": {
"SUCCESS_MESSAGE": "Resposta predeterminada afegida correctament.",
"SUCCESS_MESSAGE": "Canned response added successfully.",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"EDIT": {
"TITLE": "Edita la resposta predeterminada",
"TITLE": "Edit canned response",
"CANCEL_BUTTON_TEXT": "Cancel·la",
"FORM": {
"SHORT_CODE": {
"LABEL": "Codi curt",
"PLACEHOLDER": "Introduïu un codi curt.",
"ERROR": "És necessari el codi curt."
"LABEL": "Short code",
"PLACEHOLDER": "Please enter a shortcode.",
"ERROR": "Short code is required."
},
"CONTENT": {
"LABEL": "Missatge",
"PLACEHOLDER": "Escriviu el missatge que voleu desar com a plantilla per utilitzar-lo més tard.",
"ERROR": "El missatge és obligatori."
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
"ERROR": "Message is required."
},
"SUBMIT": "Envia"
},
"BUTTON_TEXT": "Edita",
"API": {
"SUCCESS_MESSAGE": "Resposta predeterminada actualitzada correctament.",
"SUCCESS_MESSAGE": "Canned response is updated successfully.",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
}
},
"DELETE": {
"BUTTON_TEXT": "Esborra",
"API": {
"SUCCESS_MESSAGE": "Resposta predeterminada eliminada correctament.",
"SUCCESS_MESSAGE": "Canned response deleted successfully.",
"ERROR_MESSAGE": "No s'ha pogut connectar amb el servidor Woot. Torna-ho a provar més endavant"
},
"CONFIRM": {
"TITLE": "Confirma l'esborrat",
"TITLE": "Confirm deletion",
"MESSAGE": "N'estas segur ",
"YES": "Sí, esborra ",
"NO": "No, mantén-la "
"YES": "Yes, delete ",
"NO": "No, keep "
}
}
}
@@ -8,7 +8,7 @@
},
"TAB_HEADING": "Converses",
"MENTION_HEADING": "Mencions",
"UNATTENDED_HEADING": "Sense assistència",
"UNATTENDED_HEADING": "Unattended",
"SEARCH": {
"INPUT": "Cerca persones, xats, respostes desades .."
},
@@ -36,27 +36,27 @@
}
},
"VIEW_FILTER": "Veure",
"SORT_TOOLTIP_LABEL": "Ordena les converses",
"SORT_TOOLTIP_LABEL": "Sort conversations",
"CHAT_SORT": {
"STATUS": "Estat",
"ORDER_BY": "Ordena per"
"ORDER_BY": "Order by"
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Creat",
"OLDEST": "Creat per:"
"LATEST": "Created",
"OLDEST": "Created at:"
},
"LAST_ACTIVITY": {
"NOT_ACTIVE": "Darrera activitat:",
"ACTIVE": "Darrera activitat"
"NOT_ACTIVE": "Last activity:",
"ACTIVE": "Last activity"
}
},
"SORT_ORDER_ITEMS": {
"last_activity_at_asc": {
"TEXT": "Última activitat: Més antic primer"
"TEXT": "Last activity: Oldest first"
},
"last_activity_at_desc": {
"TEXT": "Última activitat: La més nova primer"
"TEXT": "Last activity: Newest first"
},
"created_at_desc": {
"TEXT": "Created at: Newest first"
@@ -99,15 +99,15 @@
},
"CHAT_SORT_BY_FILTER": {
"TITLE": "Sort conversation",
"DROPDOWN_TITLE": "Ordenat per",
"DROPDOWN_TITLE": "Sort by",
"ITEMS": {
"LATEST": {
"NAME": "Last activity at",
"LABEL": "Last activity"
},
"CREATED_AT": {
"NAME": "Creat per",
"LABEL": "Creat per"
"NAME": "Created at",
"LABEL": "Created at"
},
"LAST_USER_MESSAGE_AT": {
"NAME": "Last user message at",
@@ -15,7 +15,7 @@
"INITIATED_FROM": "Iniciada des de",
"INITIATED_AT": "Iniciada a les",
"IP_ADDRESS": "Adreça IP",
"CREATED_AT_LABEL": "Creat",
"CREATED_AT_LABEL": "Created",
"NEW_MESSAGE": "Nou missatge",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "No hi han converses prèvies associades a aquest contacte.",
@@ -49,7 +49,7 @@
"CUSTOM_ATTRIBUTES": "Atributs personalitzats",
"CONTACT_LABELS": "Etiquetes de contactes",
"PREVIOUS_CONVERSATIONS": "Converses prèvies",
"NO_RECORDS_FOUND": "No s'han trobat atributs"
"NO_RECORDS_FOUND": "No attributes found"
}
},
"EDIT_CONTACT": {
@@ -76,17 +76,16 @@
"ERROR_MESSAGE": "S'ha produït un error; tornau-ho a provar"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Exporta",
"TITLE": "Exporta contactes",
"DESC": "Exporta contactes a través d'un fitxer CSV.",
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "S'ha produït un error; tornau-ho a provar",
"CONFIRM": {
"TITLE": "Exporta contactes",
"MESSAGE": "Esteu segur que voleu exportar tots els contactes?",
"FILTERED_MESSAGE": "Esteu segur que voleu exportar tots els contactes filtrats?",
"YES": "Sí, Exporta",
"NO": "No, cancel·la"
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
},
"DELETE_NOTE": {
@@ -152,13 +151,13 @@
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Nom del país",
"SELECT_PLACEHOLDER": "Selecciona",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Suprimeix",
"SELECT_COUNTRY": "Selecciona país"
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Introdueix el nom de la ciutat",
"LABEL": "Nom de la ciutat"
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
@@ -181,8 +180,8 @@
},
"DELETE_AVATAR": {
"API": {
"SUCCESS_MESSAGE": "L'avatar del contacte s'ha suprimit correctament",
"ERROR_MESSAGE": "No s'ha pogut esborrar l'avatar de contacte. Torneu-ho a provar."
"SUCCESS_MESSAGE": "Contact avatar deleted successfully",
"ERROR_MESSAGE": "Could not delete the contact avatar. Please try again later."
}
},
"SUCCESS_MESSAGE": "Contacte guardat correctament",
@@ -191,30 +190,30 @@
"NEW_CONVERSATION": {
"BUTTON_LABEL": "Inicia la conversa",
"TITLE": "Nova conversació",
"DESC": "Inicia una conversa nova enviant un missatge nou.",
"DESC": "Start a new conversation by sending a new message.",
"NO_INBOX": "Couldn't find an inbox to initiate a new conversation with this contact.",
"FORM": {
"TO": {
"LABEL": "Per"
"LABEL": "To"
},
"INBOX": {
"LABEL": "Inbox",
"PLACEHOLDER": "Trieu la safata d'entrada d'origen",
"PLACEHOLDER": "Choose source inbox",
"ERROR": "Select an inbox"
},
"SUBJECT": {
"LABEL": "Assumpte",
"PLACEHOLDER": "Assumpte",
"LABEL": "Subject",
"PLACEHOLDER": "Subject",
"ERROR": "Subject can't be empty"
},
"MESSAGE": {
"LABEL": "Missatge",
"PLACEHOLDER": "Write your message here",
"ERROR": "El missatge no pot estar buit"
"ERROR": "Message can't be empty"
},
"ATTACHMENTS": {
"SELECT": "Trieu fitxers",
"HELP_TEXT": "Arrossegueu i deixeu anar fitxers aquí o trieu fitxers per adjuntar"
"SELECT": "Choose files",
"HELP_TEXT": "Drag and drop files here or choose files to attach"
},
"SUBMIT": "Send message",
"CANCEL": "Cancel·la",
@@ -228,10 +227,10 @@
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "Cercar",
"SEARCH_INPUT_PLACEHOLDER": "Cerca de contactes",
"FILTER_CONTACTS": "Filtre",
"FILTER_CONTACTS_SAVE": "Desa el filtre",
"FILTER_CONTACTS_DELETE": "Suprimir el filtre",
"FILTER_CONTACTS_EDIT": "Edita segment",
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": {
"LOADING_MESSAGE": "Carregant contactes...",
"404": "No hi ha cap contacte que coincideixi amb la vostra cerca 🔍",
@@ -242,8 +241,8 @@
"CONVERSATIONS": "Converses",
"LAST_ACTIVITY": "Last Activity",
"CREATED_AT": "Created At",
"COUNTRY": "País",
"CITY": "Ciutat",
"COUNTRY": "Country",
"CITY": "City",
"SOCIAL_PROFILES": "Social Profiles",
"COMPANY": "Companyia",
"EMAIL_ADDRESS": "Adreça de correu electrònic"
@@ -257,93 +256,93 @@
},
"REMINDER": {
"ADD_BUTTON": {
"BUTTON": "Afegir",
"TITLE": "Shift + Enter per crear una tasca"
"BUTTON": "Add",
"TITLE": "Shift + Enter to create a task"
},
"FOOTER": {
"DUE_DATE": "Data de venciment",
"LABEL_TITLE": "Establir tipus"
"DUE_DATE": "Due date",
"LABEL_TITLE": "Set type"
}
},
"NOTES": {
"FETCHING_NOTES": "S'estan obtenint notes...",
"FETCHING_NOTES": "Fetching notes...",
"NOT_AVAILABLE": "There are no notes created for this contact",
"HEADER": {
"TITLE": "Notes"
},
"LIST": {
"LABEL": "afegeix una nota"
"LABEL": "added a note"
},
"ADD": {
"BUTTON": "Afegir",
"PLACEHOLDER": "Afegeix una nota",
"TITLE": "Shift + Enter per crear una nota"
"BUTTON": "Add",
"PLACEHOLDER": "Add a note",
"TITLE": "Shift + Enter to create a note"
},
"CONTENT_HEADER": {
"DELETE": "Suprimeix la nota"
"DELETE": "Delete note"
}
},
"EVENTS": {
"HEADER": {
"TITLE": "Activitats"
"TITLE": "Activities"
},
"BUTTON": {
"PILL_BUTTON_NOTES": "notes",
"PILL_BUTTON_EVENTS": "esdeveniment",
"PILL_BUTTON_EVENTS": "events",
"PILL_BUTTON_CONVO": "converses"
}
},
"CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Afegir atributs",
"BUTTON": "Afegir atributs personalitzats",
"NOT_AVAILABLE": "No hi ha atributs personalitzats disponibles per a aquest contacte.",
"ADD_BUTTON_TEXT": "Add attributes",
"BUTTON": "Add custom attribute",
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
"COPY_SUCCESSFUL": "S'ha copiat al porta-retalls amb èxit",
"SHOW_MORE": "Mostra tots els atributs",
"SHOW_LESS": "Mostra menys atributs",
"SHOW_MORE": "Show all attributes",
"SHOW_LESS": "Show less attributes",
"ACTIONS": {
"COPY": "Copia l'atribut",
"DELETE": "Suprimeix l'atribut",
"EDIT": "Edita l'atribut"
"COPY": "Copy attribute",
"DELETE": "Delete attribute",
"EDIT": "Edit attribute"
},
"ADD": {
"TITLE": "Crear atribut personalitzat",
"DESC": "Afegeix informació personalitzada a aquest contacte."
"TITLE": "Create custom attribute",
"DESC": "Add custom information to this contact."
},
"FORM": {
"CREATE": "Afegir atribut",
"CREATE": "Add attribute",
"CANCEL": "Cancel·la",
"NAME": {
"LABEL": "Personalitza el nom de l'atribut",
"PLACEHOLDER": "P. ex.: identificador de Shopify",
"ERROR": "El nom de l'atribut personalitzat no és vàlid"
"LABEL": "Custom attribute name",
"PLACEHOLDER": "Eg: shopify id",
"ERROR": "Invalid custom attribute name"
},
"VALUE": {
"LABEL": "Attribute value",
"PLACEHOLDER": "Eg: 11901 "
},
"ADD": {
"TITLE": "Crear nou atribut ",
"SUCCESS": "Atribut afegit correctament",
"ERROR": "No es pot afegir l'atribut. Si us plau, intenta-ho més tard"
"TITLE": "Create new attribute ",
"SUCCESS": "Attribute added successfully",
"ERROR": "Unable to add attribute. Please try again later"
},
"UPDATE": {
"SUCCESS": "Atribut actualitzat correctament",
"ERROR": "No es pot actualitzar l'atribut. Si us plau, intenta-ho més tard"
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"
},
"DELETE": {
"SUCCESS": "Atribut esborrat correctament",
"ERROR": "No es pot esborrar l'atribut. Si us plau, intenta-ho més tard"
"SUCCESS": "Attribute deleted successfully",
"ERROR": "Unable to delete attribute. Please try again later"
},
"ATTRIBUTE_SELECT": {
"TITLE": "Afegir atributs",
"PLACEHOLDER": "Cerca atributs",
"NO_RESULT": "No s'han trobat atributs"
"TITLE": "Add attributes",
"PLACEHOLDER": "Search attributes",
"NO_RESULT": "No attributes found"
},
"ATTRIBUTE_TYPE": {
"LIST": {
"PLACEHOLDER": "Selecciona valor",
"SEARCH_INPUT_PLACEHOLDER": "Cerca valor",
"NO_RESULT": "No s'ha trobat cap resultat"
"PLACEHOLDER": "Select value",
"SEARCH_INPUT_PLACEHOLDER": "Search value",
"NO_RESULT": "No result found"
}
}
},
@@ -366,7 +365,7 @@
"HELP_LABEL": "To be kept"
},
"SUMMARY": {
"TITLE": "Resum",
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{primaryContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{primaryContactName}</strong> will be copied to <strong>%{parentContactName}</strong>."
},
@@ -374,13 +373,13 @@
"ERROR": "ERROR_MESSAGE"
},
"FORM": {
"SUBMIT": " Fusiona contactes",
"SUBMIT": " Merge contacts",
"CANCEL": "Cancel·la",
"CHILD_CONTACT": {
"ERROR": "Seleccioneu un contacte fill per fusionar"
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contacte fusionat correctament",
"ERROR_MESSAGE": "No s'ha pogut fusionar els contactes, torna-ho a provar!"
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contacts, try again!"
}
}
}
@@ -28,24 +28,24 @@
"is_not_present": "No és present",
"is_greater_than": "És més gran que",
"is_lesser_than": "Is lesser than",
"days_before": "És x dies abans"
"days_before": "Is x days before"
},
"ATTRIBUTES": {
"NAME": "Nom",
"EMAIL": "Correu electrònic",
"PHONE_NUMBER": "Número de telèfon",
"IDENTIFIER": "Identifier",
"CITY": "Ciutat",
"COUNTRY": "País",
"CUSTOM_ATTRIBUTE_LIST": "Llista",
"CITY": "City",
"COUNTRY": "Country",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Llista",
"CUSTOM_ATTRIBUTE_NUMBER": "Número",
"CUSTOM_ATTRIBUTE_LINK": "Enllaç",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Casella de selecció",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link",
"BLOCKED": "Blocat"
"BLOCKED": "Blocked"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
@@ -1,11 +1,11 @@
{
"CONVERSATION": {
"SELECT_A_CONVERSATION": "Si us plau, selecciona una conversa al panell de lesquerra",
"CSAT_REPLY_MESSAGE": "Si us plau, valoreu la conversa",
"404": "Ho sentim, no podem trobar la conversa. Torneu-ho a provar",
"SWITCH_VIEW_LAYOUT": "Canvia el disseny",
"DASHBOARD_APP_TAB_MESSAGES": "Missatges",
"UNVERIFIED_SESSION": "La identitat d'aquest usuari no està verificada",
"CSAT_REPLY_MESSAGE": "Please rate the conversation",
"404": "Sorry, we cannot find the conversation. Please try again",
"SWITCH_VIEW_LAYOUT": "Switch the layout",
"DASHBOARD_APP_TAB_MESSAGES": "Messages",
"UNVERIFIED_SESSION": "The identity of this user is not verified",
"NO_MESSAGE_1": "Uh oh! Sembla que no hi ha missatges de clients a la safata d'entrada.",
"NO_MESSAGE_2": " per enviar un missatge a la vostra pàgina!",
"NO_INBOX_1": "Hola! Sembla que encara no heu afegit cap safata d'entrada.",
@@ -13,15 +13,15 @@
"NO_INBOX_AGENT": "Uh Oh! Sembla que no ets a cap safata d'entrada. Si us plau, poseu-vos en contacte amb l'administrador",
"SEARCH_MESSAGES": "Cerca missatges a les converses",
"EMPTY_STATE": {
"CMD_BAR": "per obrir el menú d'ordres",
"KEYBOARD_SHORTCUTS": "per veure les dreceres del teclat"
"CMD_BAR": "to open command menu",
"KEYBOARD_SHORTCUTS": "to view keyboard shortcuts"
},
"SEARCH": {
"TITLE": "Cerca missatges",
"RESULT_TITLE": "Resultats de la cerca",
"RESULT_TITLE": "Search Results",
"LOADING_MESSAGE": "S'estan restringint les dades...",
"PLACEHOLDER": "Escriu qualsevol text per cercar missatges",
"NO_MATCHING_RESULTS": "No s'han trobat resultats."
"NO_MATCHING_RESULTS": "No results found."
},
"UNREAD_MESSAGES": "Missatges no Llegits",
"UNREAD_MESSAGE": "Missatge no Llegit",
@@ -30,15 +30,15 @@
"LOADING_CONVERSATIONS": "S'estan carregant les converses",
"CANNOT_REPLY": "No pots respondre degut a",
"24_HOURS_WINDOW": "Restricció de finestra de missatges de 24 hores",
"NOT_ASSIGNED_TO_YOU": "Aquesta conversa no està assignada a tu. Vols assignar-te-la?",
"ASSIGN_TO_ME": "Assigna'm",
"TWILIO_WHATSAPP_CAN_REPLY": "Només pots respondre a aquesta conversa mitjançant una plantilla de missatge a causa de",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "Restricció de finestra de missatges de 24 hores",
"REPLYING_TO": "Estas responent a:",
"REMOVE_SELECTION": "Elimina la selecció",
"DOWNLOAD": "Descarrega",
"UNKNOWN_FILE_TYPE": "Fitxer desconegut",
"SAVE_CONTACT": "Desar",
"UNKNOWN_FILE_TYPE": "Unknown File",
"SAVE_CONTACT": "Save",
"UPLOADING_ATTACHMENTS": "Pujant fitxers adjunts...",
"REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported.",
@@ -89,7 +89,7 @@
"CANCEL": "Cancel·la"
},
"PRIORITY": {
"TITLE": "Prioritat",
"TITLE": "Priority",
"OPTIONS": {
"NONE": "Ningú",
"URGENT": "Urgent",
@@ -280,29 +280,29 @@
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"NO_RECORDS_FOUND": "No s'han trobat atributs",
"NO_RECORDS_FOUND": "No attributes found",
"UPDATE": {
"SUCCESS": "Atribut actualitzat correctament",
"ERROR": "No es pot actualitzar l'atribut. Si us plau, intenta-ho més tard"
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"
},
"ADD": {
"TITLE": "Afegir",
"SUCCESS": "Atribut afegit correctament",
"ERROR": "No es pot afegir l'atribut. Si us plau, intenta-ho més tard"
"TITLE": "Add",
"SUCCESS": "Attribute added successfully",
"ERROR": "Unable to add attribute. Please try again later"
},
"DELETE": {
"SUCCESS": "Atribut esborrat correctament",
"ERROR": "No es pot esborrar l'atribut. Si us plau, intenta-ho més tard"
"SUCCESS": "Attribute deleted successfully",
"ERROR": "Unable to delete attribute. Please try again later"
},
"ATTRIBUTE_SELECT": {
"TITLE": "Afegir atributs",
"PLACEHOLDER": "Cerca atributs",
"NO_RESULT": "No s'han trobat atributs"
"TITLE": "Add attributes",
"PLACEHOLDER": "Search attributes",
"NO_RESULT": "No attributes found"
}
},
"EMAIL_HEADER": {
"FROM": "From",
"TO": "Per",
"TO": "To",
"BCC": "Bcc",
"CC": "Cc",
"SUBJECT": "Subject"
@@ -1,7 +1,7 @@
{
"EMOJI": {
"PLACEHOLDER": "Cerca emojis",
"NOT_FOUND": "Cap emoji coincideix amb la teva cerca",
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Suprimeix"
}
}
@@ -1,5 +1,5 @@
{
"GENERAL": {
"SHOWING_RESULTS": "Es mostren {firstIndex}-{lastIndex} de {totalCount} elements"
"SHOWING_RESULTS": "Showing {firstIndex}-{lastIndex} of {totalCount} items"
}
}
@@ -3,7 +3,7 @@
"TITLE": "Configuració del compte",
"SUBMIT": "Actualització de la configuració",
"BACK": "Enrere",
"DISMISS": "Descartar",
"DISMISS": "Dismiss",
"UPDATE": {
"ERROR": "No s'ha pogut actualitzar la configuració, torna-ho a provar!",
"SUCCESS": "La configuració del compte s'ha actualitzat correctament"
@@ -15,8 +15,8 @@
"NOTE": ""
},
"ACCOUNT_ID": {
"TITLE": "ID del compte",
"NOTE": "Aquest identificador és necessari si esteu creant una integració basada en API"
"TITLE": "Account ID",
"NOTE": "This ID is required if you are building an API based integration"
},
"NAME": {
"LABEL": "Nom del compte",
@@ -24,7 +24,7 @@
"ERROR": "Introduïu un nom de compte vàlid"
},
"LANGUAGE": {
"LABEL": "Idioma del lloc",
"LABEL": "Site language",
"PLACEHOLDER": "El nom del vostre compte",
"ERROR": ""
},
@@ -41,7 +41,7 @@
"AUTO_RESOLVE_DURATION": {
"LABEL": "El nombre de dies després que un ticket es resolgui automàticament si no hi ha activitat",
"PLACEHOLDER": "30",
"ERROR": "Introduïu una durada de resolució automàtica vàlida (mínim 1 dia i màxim 999 dies)"
"ERROR": "Please enter a valid auto resolve duration (minimum 1 day and maximum 999 days)"
},
"FEATURES": {
"INBOUND_EMAIL_ENABLED": "La continuïtat de converses amb correus electrònics està habilitada per al vostre compte.",
@@ -49,8 +49,8 @@
}
},
"UPDATE_CHATWOOT": "L'actualització %{latestChatwootVersion} per Chatwoot està disponible. Si us plau, actualitza l'instancia.",
"LEARN_MORE": "Aprèn més",
"PAYMENT_PENDING": "El teu pagament està pendent. Actualitzeu la vostra informació de pagament per continuar utilitzant Chatwoot",
"LEARN_MORE": "Learn more",
"PAYMENT_PENDING": "Your payment is pending. Please update your payment information to continue using Chatwoot",
"LIMITS_UPGRADE": "Your account has exceeded the usage limits, please upgrade your plan to continue using Chatwoot",
"OPEN_BILLING": "Open billing"
},
@@ -107,7 +107,6 @@
"GENERAL": "General",
"REPORTS": "Informes",
"CONVERSATION": "Conversation",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Change Assignee",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Change Team",
@@ -460,7 +460,7 @@
"DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Upgrade your subscription to enable this feature.",
"SELF_HOSTED_DESCRIPTION": "Create user-friendly self-service portals. Help your users to access the articles and get support 24/7. Please contact your administrator to enable this feature.",
"BUTTON": {
"LEARN_MORE": "Aprèn més",
"LEARN_MORE": "Learn more",
"UPGRADE": "Upgrade"
},
"FEATURES": {
@@ -42,7 +42,7 @@
"SORT_OPTIONS": {
"NEWEST": "Newest",
"OLDEST": "Oldest",
"PRIORITY": "Prioritat"
"PRIORITY": "Priority"
},
"DISPLAY_OPTIONS": {
"SNOOZED": "Posposat",
@@ -83,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Activa la salutació del canal",
"HELP_TEXT": "Envia automàticament missatges de salutació quan els clients inicien una conversa i envien el seu primer missatge.",
"HELP_TEXT": "Automatically send a greeting message when a new conversation is created.",
"ENABLED": "Habilita",
"DISABLED": "Inhabilita"
},
@@ -169,7 +169,7 @@
},
"BANDWIDTH": {
"ACCOUNT_ID": {
"LABEL": "ID del compte",
"LABEL": "Account ID",
"PLACEHOLDER": "Please enter your Bandwidth Account ID",
"ERROR": "Aquest camp és obligatori"
},
@@ -340,7 +340,7 @@
},
"AUTH": {
"TITLE": "Choose a channel",
"DESC": "Chatwoot admet widgets de xat en directe, Facebook Messenger, WhatsApp, correus electrònics, etc., com a canals. Si voleu crear un canal personalitzat, podeu crear-lo mitjançant el canal API. Per començar, tria un dels canals següents."
"DESC": "Chatwoot supports live-chat widgets, Facebook Messenger, Twitter profiles, WhatsApp, Emails, etc., as channels. If you want to build a custom channel, you can create it using the API channel. To get started, choose one of the channels below."
},
"AGENTS": {
"TITLE": "Agents",
@@ -420,7 +420,7 @@
"BUSINESS_NAME": {
"BUTTON_TEXT": "+ Configure your business name",
"PLACEHOLDER": "Enter your business name",
"SAVE_BUTTON_TEXT": "Desar"
"SAVE_BUTTON_TEXT": "Save"
}
},
"ALLOW_MESSAGES_AFTER_RESOLVED": {
@@ -68,7 +68,7 @@
"TITLE": "Confirma l'esborrat",
"MESSAGE": "Are you sure to delete the webhook? (%{webhookURL})",
"YES": "Si, esborra ",
"NO": "No, mantén-la"
"NO": "No, manten-la"
}
}
},
@@ -79,8 +79,8 @@
"MESSAGE": "Are you sure you want to delete the integration? Doing so will result in the loss of access to conversations on your Slack workspace."
},
"HELP_TEXT": {
"TITLE": "Com utilitzar la integració de Slack?",
"BODY": "Amb aquesta integració, totes les teves converses entrants se sincronitzaran amb el canal ***%{selectedChannelName}*** del teu espai de treball de Slack. Podeu gestionar totes les converses dels vostres clients directament des del canal i no perdreu cap missatge.\n\nAquestes són les principals característiques de la integració:\n\n**Respondre a converses des de Slack:** Per respondre a una conversa al ***%{selectedChannelName}*** canal de Slack, només has d'escriure el teu missatge i enviar-lo com a fil. Això crearà una resposta al client mitjançant Chatwoot. Així de senzill!\n\n **Crear notes privades:** si vols crear notes privades en lloc de respostes, comença el teu missatge amb ***`nota:`***. Això garanteix que el vostre missatge es mantingui privat i no serà visible per al client.\n\n**Associar un perfil d'agent:** si la persona que va respondre a Slack té un perfil d'agent a Chatwoot amb el mateix correu electrònic, les respostes s'associaran amb aquest perfil d'agent automàticament. Això vol dir que podeu fer un seguiment fàcilment qui va dir què i quan. D'altra banda, quan el contestador no té un perfil d'agent associat, les respostes apareixeran des del perfil del bot al client.",
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>",
"SELECTED": "selected"
},
"SELECT_CHANNEL": {
@@ -131,7 +131,7 @@
"KEY_PLACEHOLDER": "Enter your OpenAI API key",
"BUTTONS": {
"NEED_HELP": "Need help?",
"DISMISS": "Descartar",
"DISMISS": "Dismiss",
"FINISH": "Finish Setup"
},
"DISMISS_MESSAGE": "You can setup OpenAI integration later Whenever you want.",
@@ -46,7 +46,7 @@
"DISMISS": "Dismiss suggestion"
},
"POWERED_BY": "Chatwoot AI",
"DISMISS": "Descartar",
"DISMISS": "Dismiss",
"ADD_SELECTED_LABELS": "Add selected labels",
"ADD_SELECTED_LABEL": "Add selected label",
"ADD_ALL_LABELS": "Add all labels"
@@ -3,7 +3,7 @@
"TITLE": "Entra a Chatwoot",
"EMAIL": {
"LABEL": "Correu electrònic",
"PLACEHOLDER": "exemple@nomdelacompanyia.com",
"PLACEHOLDER": "Correu electrònic p.e.: someone@exemple.com",
"ERROR": "Introduïu una adreça de correu electrònic vàlida"
},
"PASSWORD": {
@@ -16,9 +16,9 @@
"UNAUTH": "Nom d'usuari / contrasenya incorrecte. Torna-ho a provar-ho"
},
"OAUTH": {
"GOOGLE_LOGIN": "Inicieu sessió amb Google",
"BUSINESS_ACCOUNTS_ONLY": "Si us plau, utilitzeu l'adreça de correu electrònic de la vostra empresa per iniciar sessió",
"NO_ACCOUNT_FOUND": "No hem trobat cap compte per a la vostra adreça de correu electrònic."
"GOOGLE_LOGIN": "Login with Google",
"BUSINESS_ACCOUNTS_ONLY": "Please use your company email address to login",
"NO_ACCOUNT_FOUND": "We couldn't find an account for your email address."
},
"FORGOT_PASSWORD": "Has oblidat la contrasenya?",
"CREATE_NEW_ACCOUNT": "Crear un nou compte",
@@ -92,7 +92,7 @@
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Any"
"YEAR": "Month"
},
"GROUP_BY_DAY_OPTIONS": [
{
@@ -140,7 +140,7 @@
],
"BUSINESS_HOURS": "Business Hours",
"FILTER_ACTIONS": {
"CLEAR_FILTER": "Esborra els filtres",
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No s'ha trobat agents"
}
},
@@ -232,7 +232,7 @@
},
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "(Mitjana)",
"DESC": "( Promig )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
},
@@ -511,52 +511,52 @@
"SATURDAY": "Saturday"
},
"SLA_REPORTS": {
"HEADER": "Informes SLA",
"NO_RECORDS": "Les converses aplicades per l'SLA no estan disponibles.",
"LOADING": "S'estan carregant dades del SLA...",
"DOWNLOAD_SLA_REPORTS": "Descarregar informes SLA",
"DOWNLOAD_FAILED": "No s'han pogut baixar els informes SLA",
"HEADER": "SLA Reports",
"NO_RECORDS": "SLA applied conversations are not available.",
"LOADING": "Loading SLA data...",
"DOWNLOAD_SLA_REPORTS": "Download SLA reports",
"DOWNLOAD_FAILED": "Failed to download SLA Reports",
"DROPDOWN": {
"ADD_FIlTER": "Afegeix un filtre",
"CLEAR_ALL": "Esborrar tot",
"CLEAR_FILTER": "Esborra els filtres",
"ADD_FIlTER": "Add filter",
"CLEAR_ALL": "Clear all",
"CLEAR_FILTER": "Clear filter",
"EMPTY_LIST": "No s'ha trobat agents",
"NO_FILTER": "No hi ha filtres disponibles",
"SEARCH": "Cerca filtre",
"NO_FILTER": "No filters available",
"SEARCH": "Search filter",
"INPUT_PLACEHOLDER": {
"SLA": "Nom del SLA",
"AGENTS": "Nom de l'Agent",
"INBOXES": "Nom de la safata d'entrada",
"SLA": "SLA name",
"AGENTS": "Agent name",
"INBOXES": "Inbox name",
"LABELS": "Nom de l'etiqueta",
"TEAMS": "Nom de l'equip"
"TEAMS": "Team name"
},
"SLA": "Política SLA",
"INBOXES": "Safata d'entrada",
"SLA": "SLA Policy",
"INBOXES": "Inbox",
"AGENTS": "Agent",
"LABELS": "Etiqueta",
"TEAMS": "Equip"
"LABELS": "Label",
"TEAMS": "Team"
},
"METRICS": {
"HIT_RATE": {
"LABEL": "Proporció d'encerts",
"TOOLTIP": "El percentatge de SLA creats s'ha completat correctament"
"LABEL": "Hit Rate",
"TOOLTIP": "Percentage of SLAs created were completed successfully"
},
"NO_OF_MISSES": {
"LABEL": "Nombre de faltes",
"TOOLTIP": "SLA total faltes en un període determinat"
"LABEL": "Number of Misses",
"TOOLTIP": "Total SLA misses in a certain period"
},
"NO_OF_CONVERSATIONS": {
"LABEL": "Número de Converses",
"TOOLTIP": "Nombre total de converses amb SLA"
"LABEL": "Number of Conversations",
"TOOLTIP": "Total number of conversations with SLA"
}
},
"TABLE": {
"HEADER": {
"POLICY": "Política",
"CONVERSATION": "Converses",
"POLICY": "Policy",
"CONVERSATION": "Conversation",
"AGENT": "Agent"
},
"VIEW_DETAILS": "Veure Detalls"
"VIEW_DETAILS": "View Details"
}
}
}
@@ -4,12 +4,12 @@
"ALL": "Totes",
"CONTACTS": "Contactes",
"CONVERSATIONS": "Converses",
"MESSAGES": "Missatges"
"MESSAGES": "Messages"
},
"SECTION": {
"CONTACTS": "Contactes",
"CONVERSATIONS": "Converses",
"MESSAGES": "Missatges"
"MESSAGES": "Messages"
},
"EMPTY_STATE": "No %{item} found for query '%{query}'",
"EMPTY_STATE_FULL": "No results found for query '%{query}'",
@@ -91,9 +91,9 @@
"CONVERSATION_MENTION": "Enviar notificacions per mail quan siguis esmentat en una conversació",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "Envia notificacions per correu electrònic quan es creï un missatge nou en una conversa assignada",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send email notifications when a new message is created in a participating conversation",
"SLA_MISSED_FIRST_RESPONSE": "Envia notificacions per correu electrònic quan una conversa no compleixi un SLA de primera resposta",
"SLA_MISSED_NEXT_RESPONSE": "Envia notificacions per correu electrònic quan una conversa es perdi la següent resposta SLA",
"SLA_MISSED_RESOLUTION": "Envia notificacions per correu electrònic quan una conversa no resol un SLA"
"SLA_MISSED_FIRST_RESPONSE": "Send email notifications when a conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
},
"NOTIFICATIONS": {
"TITLE": "Notification preferences",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Les teves preferències de notificació shan actualitzat correctament",
@@ -126,9 +125,9 @@
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "Send push notifications when a new message is created in a participating conversation",
"HAS_ENABLED_PUSH": "Heu activat les notificacions per a aquest navegador.",
"REQUEST_PUSH": "Activa les notificacions",
"SLA_MISSED_FIRST_RESPONSE": "Envia notificacions automàtiques quan una conversa no tingui un SLA de primera resposta",
"SLA_MISSED_NEXT_RESPONSE": "Envia notificacions automàtiques quan una conversa no s'aconsegueixi la següent resposta SLA",
"SLA_MISSED_RESOLUTION": "Envia notificacions quan una conversa no resol el SLA"
"SLA_MISSED_FIRST_RESPONSE": "Send push notifications when a conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "Send push notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send push notifications when a conversation misses resolution SLA"
},
"PROFILE_IMAGE": {
"LABEL": "Imatge del Perfil"
@@ -11,7 +11,7 @@
"COMPANY_NAME": {
"LABEL": "Company name",
"PLACEHOLDER": "Enter your company name. eg: Wayne Enterprises",
"ERROR": "El nom de l'empresa és massa curt."
"ERROR": "Company name is too short"
},
"FULL_NAME": {
"LABEL": "Nom complet",
@@ -20,7 +20,7 @@
},
"EMAIL": {
"LABEL": "Email de treball",
"PLACEHOLDER": "Introdueix la teva adreça de correu electrònica de la feina. P. ex.: bruce@wayne.enterprises",
"PLACEHOLDER": "Introdueix la teva adreça email de treball. ex: bruce@wayne.enterprises",
"ERROR": "Please enter a valid work email address"
},
"PASSWORD": {
@@ -32,7 +32,7 @@
"CONFIRM_PASSWORD": {
"LABEL": "Confirma la contrasenya",
"PLACEHOLDER": "Confirma la contrasenya",
"ERROR": "La contrasenya no coincideix."
"ERROR": "Les contrasenyes no coincideixen"
},
"API": {
"SUCCESS_MESSAGE": "Registrat correctament",
@@ -1,10 +1,10 @@
{
"SLA": {
"HEADER": "SLA",
"ADD_ACTION": "Afegeix SLA",
"ADD_ACTION_LONG": "Crea una nova política de SLA",
"DESCRIPTION": "Els acords de nivell de servei (SLA) són contractes que defineixen expectatives clares entre el vostre equip i els clients. Estableixen estàndards de temps de resposta i resolució, creen un marc de responsabilitat i garanteixen una experiència coherent i d'alta qualitat.",
"LEARN_MORE": "Més informació sobre SLA",
"ADD_ACTION": "Add SLA",
"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",
"LOADING": "Fetching SLAs",
"PAYWALL": {
"TITLE": "Upgrade to create SLAs",
@@ -21,10 +21,10 @@
"LIST": {
"404": "There are no SLAs available in this account.",
"EMPTY": {
"TITLE_1": "Empresa P0",
"DESC_1": "Problemes plantejats pels clients empresarials, que requereixen atenció immediata.",
"TITLE_2": "Empresa P1",
"DESC_2": "Problemes plantejats pels clients empresarials, que cal reconèixer ràpidament."
"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",
@@ -77,7 +77,7 @@
},
"ADD": {
"TITLE": "Add SLA",
"DESC": "Promeses amistoses per a un gran servei!",
"DESC": "Friendly promises for great service!",
"API": {
"SUCCESS_MESSAGE": "SLA added successfully",
"ERROR_MESSAGE": "S'ha produït un error; tornau-ho a provar"
@@ -97,12 +97,12 @@
}
},
"EVENTS": {
"TITLE": "Faltes SLA",
"TITLE": "SLA Misses",
"FRT": "Primer temps de resposta",
"NRT": "Pròxim temps de resposta",
"RT": "Temps de resolució",
"SHOW_MORE": "{count} més",
"HIDE": "Amaga {count} files"
"NRT": "Next response time",
"RT": "Resolution time",
"SHOW_MORE": "{count} more",
"HIDE": "Hide {count} rows"
}
}
}
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Načítání agentů",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL": "Odložit",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -107,7 +107,6 @@
"GENERAL": "General",
"REPORTS": "Zprávy",
"CONVERSATION": "Conversation",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Change Assignee",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Change Team",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Vaše předvolby oznámení byly úspěšně aktualizovány",
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Indlæser agenter",
"UPDATE": {
"CHANGE_STATUS": "Skift status",
"SNOOZE_UNTIL": "Udsæt",
"SNOOZE_UNTIL_NEXT_REPLY": "Udsæt til næste svar.",
"UPDATE_SUCCESFUL": "Samtalens status blev opdateret.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -107,7 +107,6 @@
"GENERAL": "Generelt",
"REPORTS": "Rapporter",
"CONVERSATION": "Samtale",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Skift Modtager",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Skift Team",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Dine notifikationsindstillinger er opdateret",
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Agenten werden geladen",
"UPDATE": {
"CHANGE_STATUS": "Status ändern",
"SNOOZE_UNTIL": "Erinnern",
"SNOOZE_UNTIL_NEXT_REPLY": "Bis zur nächsten Antwort zurückstellen.",
"UPDATE_SUCCESFUL": "Der Konversationsstatus wurde erfolgreich aktualisiert.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "Kontakte exportieren",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -1,7 +1,7 @@
{
"CONVERSATION": {
"SELECT_A_CONVERSATION": "Bitte wählen Sie eine Konversation aus dem linken Bereich aus",
"CSAT_REPLY_MESSAGE": "Bitte bewerten Sie diese Konversation",
"CSAT_REPLY_MESSAGE": "Bitte bewerten Sie diese Unterhaltung",
"404": "Wir können die Konversation leider nicht finden. Bitte versuche es erneut",
"SWITCH_VIEW_LAYOUT": "Layout wechseln",
"DASHBOARD_APP_TAB_MESSAGES": "Nachrichten",
@@ -49,7 +49,7 @@
"NO_RESPONSE": "Keine Antwort",
"RATING_TITLE": "Bewertung",
"FEEDBACK_TITLE": "Feedback",
"REPLY_MESSAGE_NOT_FOUND": "Nachricht nicht verfügbar",
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Labels anzeigen",
"HIDE_LABELS": "Labels ausblenden"
@@ -150,8 +150,8 @@
"SEND": "Senden",
"CREATE": "Notiz hinzufügen",
"INSERT_READ_MORE": "Mehr erfahren",
"DISMISS_REPLY": "Antwort verwerfen",
"REPLYING_TO": "Antwort auf:",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "Rich Text Editor anzeigen",
"TIP_EMOJI_ICON": "Emoji-Auswahl anzeigen",
"TIP_ATTACH_ICON": "Dateien anhängen",
@@ -206,7 +206,7 @@
},
"CONTEXT_MENU": {
"COPY": "Kopieren",
"REPLY_TO": "Auf diese Nachricht antworten",
"REPLY_TO": "Reply to this message",
"DELETE": "Löschen",
"CREATE_A_CANNED_RESPONSE": "Zu vorgefertigten Antworten hinzufügen",
"TRANSLATE": "Übersetzen",
@@ -107,7 +107,6 @@
"GENERAL": "Generell",
"REPORTS": "Berichte",
"CONVERSATION": "Konversation",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Beauftragten ändern",
"CHANGE_PRIORITY": "Priorität ändern",
"CHANGE_TEAM": "Team wechseln",
@@ -717,7 +717,7 @@
"IN_A_DAY": "Wir antworten üblicherweise innerhalb eines Tages"
},
"FOOTER": {
"START_CONVERSATION_BUTTON_TEXT": "Konversation beginnen",
"START_CONVERSATION_BUTTON_TEXT": "Unterhaltung beginnen",
"CHAT_INPUT_PLACEHOLDER": "Schreiben Sie Ihre Nachricht"
},
"BODY": {
@@ -1,7 +1,7 @@
{
"RESET_PASSWORD": {
"TITLE": "Passwort zurücksetzen",
"DESCRIPTION": "Geben Sie die E-Mail-Adresse ein, mit der Sie sich bei Chatwoot anmelden, um die Anweisungen zum Zurücksetzen des Passworts zu erhalten.",
"DESCRIPTION": "Enter the email address you use to log in to Chatwoot to get the password reset instructions.",
"GO_BACK_TO_LOGIN": "If you want to go back to the login page,",
"EMAIL": {
"LABEL": "E-Mail",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Ihre Benachrichtigungseinstellungen wurden erfolgreich aktualisiert",
@@ -190,9 +189,9 @@
"TRIAL_MESSAGE": "Tage der Testversion verbleibend.",
"TRAIL_BUTTON": "Jetzt kaufen",
"DELETED_USER": "Gelöschter Benutzer",
"EMAIL_VERIFICATION_PENDING": "Es scheint, dass Sie Ihre E-Mail-Adresse noch nicht verifiziert haben. Bitte überprüfen Sie Ihren Posteingang auf die Verifizierungs-E-Mail.",
"RESEND_VERIFICATION_MAIL": "Verifizierungs-E-Mail erneut senden",
"EMAIL_VERIFICATION_SENT": "Verifizierungs-E-Mail wurde versendet. Bitte überprüfen Sie Ihren Posteingang.",
"EMAIL_VERIFICATION_PENDING": "It seems that you haven't verified your email address yet. Please check your inbox for the verification email.",
"RESEND_VERIFICATION_MAIL": "Resend verification email",
"EMAIL_VERIFICATION_SENT": "Verification email has been sent. Please check your inbox.",
"ACCOUNT_SUSPENDED": {
"TITLE": "Konto gesperrt",
"MESSAGE": "Ihr Account wurde gesperrt. Wenden Sie sich für weitere Informationen bitte an das Support-Team."
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Φόρτωση πρακτόρων",
"UPDATE": {
"CHANGE_STATUS": "Αλλαγή κατάστασης",
"SNOOZE_UNTIL": "Αναβολή",
"SNOOZE_UNTIL_NEXT_REPLY": "Αναβολή έως την επόμενη απάντηση.",
"UPDATE_SUCCESFUL": "Η κατάσταση συνομιλίας ενημερώθηκε με επιτυχία.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -84,7 +84,6 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -107,7 +107,6 @@
"GENERAL": "Γενικά",
"REPORTS": "Αναφορές",
"CONVERSATION": "Συνομιλία",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Αλλαγή Αναδόχου",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Αλλαγή Ομάδας",
@@ -109,8 +109,7 @@
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
}
},
"API": {
"UPDATE_SUCCESS": "Οι προτιμήσεις σας για τις ειδοποιήσεις ενημερώθηκαν",

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