Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b72f3a26b | ||
|
|
70e9fbc53c | ||
|
|
186d9e0457 | ||
|
|
295bc94fa3 | ||
|
|
83e96956fc | ||
|
|
190416d765 | ||
|
|
3bf370dbba | ||
|
|
5b4d3c1f83 | ||
|
|
475ca365c0 | ||
|
|
6ff0c67501 | ||
|
|
3bd4570580 | ||
|
|
fb75f8783f | ||
|
|
135357a455 | ||
|
|
5670f38eb4 | ||
|
|
251e9980fd | ||
|
|
2b50909d9b | ||
|
|
290dd3abf5 | ||
|
|
477dad1e8e | ||
|
|
a9123e7d66 | ||
|
|
6b1389a489 | ||
|
|
b19747a23a | ||
|
|
64d0aae672 | ||
|
|
9967101b48 | ||
|
|
6d3926413b | ||
|
|
3489e21753 | ||
|
|
654fcd43f2 | ||
|
|
6d541a8d39 | ||
|
|
07b9dbc720 | ||
|
|
284977687c | ||
|
|
18dc77aa56 | ||
|
|
8aad8ad38e | ||
|
|
098f7a77b6 | ||
|
|
9c22d791c4 | ||
|
|
4d344a47dc | ||
|
|
38dbda9378 | ||
|
|
a4c3d3d8c0 | ||
|
|
688218de0a | ||
|
|
a8d53a6df4 | ||
|
|
2a90652f05 | ||
|
|
270f3c6a80 | ||
|
|
ad1539c6cf | ||
|
|
349f55b558 | ||
|
|
ef91b8bb42 | ||
|
|
de4c837885 | ||
|
|
a62beffeef | ||
|
|
b8f6fe5bb7 | ||
|
|
b866886b55 | ||
|
|
b88236e86e | ||
|
|
11ee741716 | ||
|
|
ac93290c9a | ||
|
|
03719cede0 | ||
|
|
a5c50354fc | ||
|
|
a452ce9e84 | ||
|
|
28bf9fa5f9 | ||
|
|
73a90f2841 | ||
|
|
a90ffe6264 | ||
|
|
412b72db7c | ||
|
|
8aa49f69d2 | ||
|
|
550b408656 | ||
|
|
b103747584 | ||
|
|
d6d38cdd7d | ||
|
|
7331b0a98d | ||
|
|
38d991f19a | ||
|
|
2f46cf162d | ||
|
|
6a6c3b7c27 | ||
|
|
15c710ec01 | ||
|
|
787c99bfd2 | ||
|
|
76b1769731 | ||
|
|
ce5b7e310e | ||
|
|
4a2b64cc7c | ||
|
|
28cc377a8d | ||
|
|
ae76548f8e | ||
|
|
87e8c50ee1 | ||
|
|
8cacf95aae | ||
|
|
e7ae724488 | ||
|
|
05da860aca | ||
|
|
290c2ff4c5 | ||
|
|
9d95f73ec2 | ||
|
|
3396087047 | ||
|
|
f6bef53252 | ||
|
|
c22f8a07c5 | ||
|
|
e8157a2c0e | ||
|
|
7a8b1700ea | ||
|
|
c811d698df | ||
|
|
ae9b2daf84 | ||
|
|
73bd05a941 | ||
|
|
9ea746df9a | ||
|
|
44d363f437 | ||
|
|
391b139652 | ||
|
|
55d1e249dc | ||
|
|
852f23f0f5 | ||
|
|
443991078a | ||
|
|
5c3ba3ccf2 | ||
|
|
f161d8bf23 | ||
|
|
aebb93f88c | ||
|
|
2a824151cb | ||
|
|
219ce15302 | ||
|
|
1c363262b6 | ||
|
|
1dcafe6e47 | ||
|
|
491569fa4f | ||
|
|
c421f75bca | ||
|
|
52fc0c683d | ||
|
|
9145b513c8 | ||
|
|
1f7136860b | ||
|
|
3758a07a29 | ||
|
|
de9fea0983 | ||
|
|
11917b7bd7 | ||
|
|
d80247b5ee | ||
|
|
c8ade357d7 | ||
|
|
c548f71c1a | ||
|
|
faf6b29626 | ||
|
|
6535d19945 | ||
|
|
1844758848 | ||
|
|
567092b9a8 | ||
|
|
59e5a5feea | ||
|
|
52ce0baef7 | ||
|
|
22a0dac4d6 | ||
|
|
2f215a9cff | ||
|
|
8f8c7fdfe7 | ||
|
|
f046d6be32 | ||
|
|
3e5928c8e9 | ||
|
|
1fe5a3c8fb | ||
|
|
2b933f0166 | ||
|
|
d617266258 | ||
|
|
65a254705a | ||
|
|
1ede5bdeec | ||
|
|
3a348d594d | ||
|
|
6b57264fec | ||
|
|
5aaa0400af | ||
|
|
c9bd109dd8 | ||
|
|
8f47496373 | ||
|
|
0990e33860 | ||
|
|
c93c6aed5f | ||
|
|
0381aa188a | ||
|
|
90e212a81a | ||
|
|
76e1643ccf | ||
|
|
0b56add844 | ||
|
|
e9b99a5811 | ||
|
|
a910758fd8 | ||
|
|
aa68525b6b | ||
|
|
ba1f363a28 | ||
|
|
b8e64722a7 | ||
|
|
153d0371ff | ||
|
|
c66f8600ec | ||
|
|
38b5c61d51 | ||
|
|
81f5c9aa12 | ||
|
|
532c5c3324 | ||
|
|
2dca1eee89 | ||
|
|
851f622fe1 | ||
|
|
1503ddeda4 | ||
|
|
c645ba3922 | ||
|
|
5d64079a5e | ||
|
|
82e296b152 | ||
|
|
016a8541c1 | ||
|
|
d289daf6ca | ||
|
|
c8cc157683 | ||
|
|
3fd506c27f | ||
|
|
d8a1fdbce5 | ||
|
|
6840108f30 | ||
|
|
d7288835fa | ||
|
|
31e1d59d58 | ||
|
|
4f55a66fba | ||
|
|
312c8a1a74 | ||
|
|
79f7afaeec | ||
|
|
4e7ca49297 | ||
|
|
db51d2d011 | ||
|
|
6f86944758 | ||
|
|
d74798fcd5 | ||
|
|
394419ce95 | ||
|
|
4fe9b88642 | ||
|
|
0f8037b6fc | ||
|
|
38938fe776 | ||
|
|
02ca4136ad | ||
|
|
da4049d18a | ||
|
|
088608abcf | ||
|
|
afb3c0109b | ||
|
|
c54973eeb2 | ||
|
|
cb2785096e | ||
|
|
27f35a7dd8 | ||
|
|
59790e38da | ||
|
|
461f1b3bf0 | ||
|
|
4056a144e4 | ||
|
|
31681610f2 | ||
|
|
a8b33023f8 | ||
|
|
8c5204d0c9 | ||
|
|
aa5242fdb0 | ||
|
|
b591893486 | ||
|
|
a118bef98e | ||
|
|
daa54f6866 | ||
|
|
c38e7af0e7 | ||
|
|
d6f862a85c | ||
|
|
13f742fd68 | ||
|
|
b74e78f698 | ||
|
|
5d8646a3f3 | ||
|
|
453eba9a81 | ||
|
|
6beb355f29 | ||
|
|
980eca46c6 | ||
|
|
0868a9af70 | ||
|
|
da6fbe8579 | ||
|
|
2c814fff99 | ||
|
|
c1c44512d4 | ||
|
|
1138195846 | ||
|
|
84d31b91f4 | ||
|
|
2df2459674 | ||
|
|
81e18aaafa | ||
|
|
f07a5215cf | ||
|
|
1899a31416 | ||
|
|
60f5145365 | ||
|
|
e58736e746 | ||
|
|
a9fd459cf4 | ||
|
|
7235e44a9d | ||
|
|
ddd1fb5396 | ||
|
|
a97cf6e4ad | ||
|
|
4ecc92ad6d | ||
|
|
ff70fbe2e4 | ||
|
|
4f27f1605a | ||
|
|
e00b54ce07 | ||
|
|
df20a59a6c | ||
|
|
bb6f7b9fcd | ||
|
|
e28c6312e4 | ||
|
|
fc119d14d9 | ||
|
|
9243b6367c | ||
|
|
5af8881a5a | ||
|
|
70029177da | ||
|
|
1abb04f592 | ||
|
|
355b587c2c | ||
|
|
b22a903446 | ||
|
|
2d5993dfe3 | ||
|
|
db50f42054 | ||
|
|
f8afc1509f |
@@ -1,3 +1,4 @@
|
||||
---
|
||||
ignore:
|
||||
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)
|
||||
- GHSA-57hq-95w6-v4fc # Devise confirmable race condition — patched locally in User model (remove once on Devise 5+)
|
||||
|
||||
@@ -94,6 +94,10 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
|
||||
# react component
|
||||
dist
|
||||
|
||||
.codex/
|
||||
.claude/
|
||||
CLAUDE.local.md
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.11.2
|
||||
4.12.0
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
module Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
skip_before_action :check_authorization, only: [:health, :register_webhook]
|
||||
before_action :check_admin_authorization?, only: [:register_webhook]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health, :register_webhook]
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def register_webhook
|
||||
Whatsapp::WebhookSetupService.new(@inbox.channel).register_callback
|
||||
|
||||
render json: { message: 'Webhook registered successfully' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX WEBHOOK] Webhook registration failed: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||
before_action :validate_limit, only: [:create]
|
||||
# we are already handling the authorization in fetch inbox
|
||||
before_action :check_authorization, except: [:show, :health]
|
||||
before_action :validate_whatsapp_cloud_channel, only: [:health]
|
||||
before_action :check_authorization, except: [:show]
|
||||
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@@ -70,23 +71,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
end
|
||||
|
||||
def health
|
||||
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
|
||||
render json: health_data
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_inbox
|
||||
@@ -98,12 +82,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
end
|
||||
|
||||
def validate_whatsapp_cloud_channel
|
||||
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
|
||||
|
||||
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
|
||||
end
|
||||
|
||||
def create_channel
|
||||
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
|
||||
|
||||
@@ -200,18 +178,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
def get_channel_attributes(channel_type)
|
||||
channel_type.constantize.const_defined?(:EDITABLE_ATTRS) ? channel_type.constantize::EDITABLE_ATTRS.presence : []
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
@@ -126,7 +126,7 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
return unless @hook&.access_token
|
||||
|
||||
begin
|
||||
linear_client = Linear.new(@hook.access_token)
|
||||
linear_client = Linear.new(@hook.access_token, refresh_token: @hook.settings&.[]('refresh_token'))
|
||||
linear_client.revoke_token
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
|
||||
|
||||
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
||||
contact = @contact
|
||||
end
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac?
|
||||
@contact_inbox.update(hmac_verified: true) if should_verify_hmac?
|
||||
|
||||
identify_contact(contact)
|
||||
end
|
||||
|
||||
@@ -2,6 +2,8 @@ class Linear::CallbacksController < ApplicationController
|
||||
include Linear::IntegrationHelper
|
||||
|
||||
def show
|
||||
return redirect_to(safe_linear_redirect_uri) if params[:code].blank? || account_id.blank?
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/linear/callback"
|
||||
@@ -10,7 +12,7 @@ class Linear::CallbacksController < ApplicationController
|
||||
handle_response
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to linear_redirect_uri
|
||||
redirect_to safe_linear_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
@@ -31,22 +33,19 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def handle_response
|
||||
hook = account.hooks.new(
|
||||
raise ArgumentError, 'Missing access token in Linear OAuth response' if parsed_body['access_token'].blank?
|
||||
|
||||
hook = account.hooks.find_or_initialize_by(app_id: 'linear')
|
||||
hook.assign_attributes(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'linear',
|
||||
settings: {
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
settings: merged_integration_settings(hook.settings)
|
||||
)
|
||||
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
|
||||
hook.save!
|
||||
redirect_to linear_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Linear callback error: #{e.message}")
|
||||
redirect_to linear_redirect_uri
|
||||
redirect_to safe_linear_redirect_uri
|
||||
end
|
||||
|
||||
def account
|
||||
@@ -54,19 +53,47 @@ class Linear::CallbacksController < ApplicationController
|
||||
end
|
||||
|
||||
def account_id
|
||||
return unless params[:state]
|
||||
return @account_id if instance_variable_defined?(:@account_id)
|
||||
|
||||
verify_linear_token(params[:state])
|
||||
@account_id = params[:state].present? ? verify_linear_token(params[:state]) : nil
|
||||
end
|
||||
|
||||
def linear_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
||||
end
|
||||
|
||||
def safe_linear_redirect_uri
|
||||
return base_url if account_id.blank?
|
||||
|
||||
linear_redirect_uri
|
||||
rescue StandardError
|
||||
base_url
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def integration_settings
|
||||
{
|
||||
token_type: parsed_body['token_type'],
|
||||
expires_in: parsed_body['expires_in'],
|
||||
expires_on: expires_on,
|
||||
scope: parsed_body['scope'],
|
||||
refresh_token: parsed_body['refresh_token']
|
||||
}.compact
|
||||
end
|
||||
|
||||
def merged_integration_settings(existing_settings)
|
||||
existing_settings.to_h.with_indifferent_access.merge(integration_settings)
|
||||
end
|
||||
|
||||
def expires_on
|
||||
return if parsed_body['expires_in'].blank?
|
||||
|
||||
(Time.current.utc + parsed_body['expires_in'].to_i.seconds).to_s
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@search_query = list_params[:query]
|
||||
@articles = @portal.articles.published.includes(:category, :author)
|
||||
|
||||
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
|
||||
@@ -73,7 +74,9 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.permit(:query, :locale, :sort, :status, :page, :per_page)
|
||||
@list_params ||= params.permit(:query, :locale, :sort, :status, :page, :per_page).tap do |permitted|
|
||||
permitted[:query] = permitted[:query].to_s.strip.presence
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
if @web_widget.allowed_domains.blank?
|
||||
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
|
||||
response.headers.delete('X-Frame-Options')
|
||||
else
|
||||
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
||||
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
||||
end
|
||||
end
|
||||
|
||||
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
|
||||
# which cannot match any domain in frame-ancestors. When the per-inbox flag
|
||||
# is enabled, skip frame-ancestors for these requests.
|
||||
def embedded_from_non_web_origin?
|
||||
return false unless @web_widget.allow_mobile_webview?
|
||||
|
||||
origin = request.headers['Origin']
|
||||
origin.blank? || origin == 'null' || origin&.start_with?('file://')
|
||||
end
|
||||
end
|
||||
|
||||
WidgetsController.prepend_mod_with('WidgetsController')
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
module TimezoneHelper
|
||||
def timezone_name_from_params(timezone, offset)
|
||||
return timezone if timezone.present? && ActiveSupport::TimeZone[timezone].present?
|
||||
|
||||
timezone_name_from_offset(offset)
|
||||
end
|
||||
|
||||
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
|
||||
# would return the timezone without considering day light savings. To get the correct timezone,
|
||||
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
|
||||
|
||||
@@ -15,6 +15,12 @@ class ApiClient {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
get accountIdFromRoute() {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_ACCOUNT_ID__) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
return window.__WOOT_ACCOUNT_ID__;
|
||||
}
|
||||
|
||||
const isInsideAccountScopedURLs =
|
||||
window.location.pathname.includes('/app/accounts');
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ class CacheEnabledApiClient extends ApiClient {
|
||||
}
|
||||
|
||||
get(cache = false) {
|
||||
if (cache) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (cache && !window.__WOOT_ISOLATED_SHELL__) {
|
||||
return this.getFromCache();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ import {
|
||||
} from '../store/utils/api';
|
||||
|
||||
export default {
|
||||
validityCheck() {
|
||||
async validityCheck() {
|
||||
if (this.hasAuthToken()) {
|
||||
const urlData = endPoints('profileUpdate');
|
||||
const response = await axios.get(urlData.url);
|
||||
// to match the response signature of the validityCheck endpoint
|
||||
return Promise.resolve({ data: { payload: response } });
|
||||
}
|
||||
|
||||
const urlData = endPoints('validityCheck');
|
||||
return axios.get(urlData.url);
|
||||
},
|
||||
@@ -31,6 +38,10 @@ export default {
|
||||
hasAuthCookie() {
|
||||
return !!Cookies.get('cw_d_session_info');
|
||||
},
|
||||
hasAuthToken() {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
return !!window.__WOOT_ACCESS_TOKEN__;
|
||||
},
|
||||
getAuthData() {
|
||||
if (this.hasAuthCookie()) {
|
||||
const savedAuthInfo = Cookies.get('cw_d_session_info');
|
||||
|
||||
@@ -9,6 +9,10 @@ class InboxHealthAPI extends ApiClient {
|
||||
getHealthStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/health`);
|
||||
}
|
||||
|
||||
registerWebhook(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/register_webhook`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new InboxHealthAPI();
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/* global axios */
|
||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class Inboxes extends CacheEnabledApiClient {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const BaseClass = window.__WOOT_ISOLATED_SHELL__
|
||||
? ApiClient
|
||||
: CacheEnabledApiClient;
|
||||
|
||||
class Inboxes extends BaseClass {
|
||||
constructor() {
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// scss-lint:disable PropertySortOrder
|
||||
|
||||
// NEXT COLORS START
|
||||
@layer base {
|
||||
// NEXT COLORS START
|
||||
:root {
|
||||
:root,
|
||||
:host {
|
||||
// slate
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
@@ -138,6 +140,48 @@
|
||||
--label-background: 247 247 247;
|
||||
--label-border: 0, 0, 0, 0.04;
|
||||
|
||||
// Bubble-specific colors (override these for custom theming)
|
||||
--bubble-agent-bg: var(--solid-blue);
|
||||
--bubble-agent-text: var(--slate-12);
|
||||
--bubble-user-bg: var(--slate-4);
|
||||
--bubble-user-text: var(--slate-12);
|
||||
--bubble-private-bg: var(--solid-amber);
|
||||
--bubble-private-text: var(--amber-12);
|
||||
--bubble-bot-bg: var(--solid-iris);
|
||||
--bubble-bot-text: var(--slate-12);
|
||||
|
||||
// Bubble border customization
|
||||
--bubble-border-width: 0;
|
||||
--bubble-agent-border: var(--blue-6);
|
||||
--bubble-user-border: var(--slate-6);
|
||||
--bubble-private-border: var(--amber-6);
|
||||
--bubble-bot-border: var(--iris-6);
|
||||
|
||||
// Bubble spacing (ratio of 1 = default, 1.5 = 50% larger, 0.5 = 50% smaller)
|
||||
--bubble-spacing-ratio: 1;
|
||||
--bubble-radius: calc(0.75rem * var(--bubble-spacing-ratio));
|
||||
--bubble-radius-sm: calc(0.125rem * var(--bubble-spacing-ratio));
|
||||
--bubble-radius-md: calc(0.375rem * var(--bubble-spacing-ratio));
|
||||
--bubble-radius-lg: calc(0.5rem * var(--bubble-spacing-ratio));
|
||||
--bubble-padding-x: calc(1rem * var(--bubble-spacing-ratio));
|
||||
--bubble-padding-y: calc(0.75rem * var(--bubble-spacing-ratio));
|
||||
|
||||
// Bubble meta text colors (per variant)
|
||||
--bubble-agent-meta: var(--slate-11);
|
||||
--bubble-user-meta: var(--slate-11);
|
||||
--bubble-private-meta: var(--amber-12);
|
||||
--bubble-bot-meta: var(--slate-11);
|
||||
|
||||
// Message status colors (per variant)
|
||||
--bubble-agent-status: var(--slate-10);
|
||||
--bubble-agent-status-read: 126 182 255;
|
||||
--bubble-user-status: var(--slate-10);
|
||||
--bubble-user-status-read: 126 182 255;
|
||||
--bubble-private-status: var(--amber-11);
|
||||
--bubble-private-status-read: 126 182 255;
|
||||
--bubble-bot-status: var(--slate-10);
|
||||
--bubble-bot-status-read: 126 182 255;
|
||||
|
||||
--alpha-1: 215, 215, 215, 0.22;
|
||||
--alpha-2: 196, 197, 198, 0.22;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
@@ -147,7 +191,8 @@
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.dark,
|
||||
:host(.dark) {
|
||||
// slate
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
@@ -283,6 +328,48 @@
|
||||
--label-background: 36 38 45;
|
||||
--label-border: 255, 255, 255, 0.03;
|
||||
|
||||
// Bubble-specific colors (override these for custom theming)
|
||||
--bubble-agent-bg: var(--solid-blue);
|
||||
--bubble-agent-text: var(--slate-12);
|
||||
--bubble-user-bg: var(--slate-4);
|
||||
--bubble-user-text: var(--slate-12);
|
||||
--bubble-private-bg: var(--solid-amber);
|
||||
--bubble-private-text: var(--amber-12);
|
||||
--bubble-bot-bg: var(--solid-iris);
|
||||
--bubble-bot-text: var(--slate-12);
|
||||
|
||||
// Bubble border customization
|
||||
--bubble-border-width: 0;
|
||||
--bubble-agent-border: var(--blue-6);
|
||||
--bubble-user-border: var(--slate-6);
|
||||
--bubble-private-border: var(--amber-6);
|
||||
--bubble-bot-border: var(--iris-6);
|
||||
|
||||
// Bubble spacing (inherited from light mode, can be overridden)
|
||||
--bubble-spacing-ratio: 1;
|
||||
--bubble-radius: calc(0.75rem * var(--bubble-spacing-ratio));
|
||||
--bubble-radius-sm: calc(0.125rem * var(--bubble-spacing-ratio));
|
||||
--bubble-radius-md: calc(0.375rem * var(--bubble-spacing-ratio));
|
||||
--bubble-radius-lg: calc(0.5rem * var(--bubble-spacing-ratio));
|
||||
--bubble-padding-x: calc(1rem * var(--bubble-spacing-ratio));
|
||||
--bubble-padding-y: calc(0.75rem * var(--bubble-spacing-ratio));
|
||||
|
||||
// Bubble meta text colors (per variant)
|
||||
--bubble-agent-meta: var(--slate-11);
|
||||
--bubble-user-meta: var(--slate-11);
|
||||
--bubble-private-meta: var(--amber-12);
|
||||
--bubble-bot-meta: var(--slate-11);
|
||||
|
||||
// Message status colors (per variant)
|
||||
--bubble-agent-status: var(--slate-10);
|
||||
--bubble-agent-status-read: 126 182 255;
|
||||
--bubble-user-status: var(--slate-10);
|
||||
--bubble-user-status-read: 126 182 255;
|
||||
--bubble-private-status: var(--amber-11);
|
||||
--bubble-private-status-read: 126 182 255;
|
||||
--bubble-bot-status: var(--slate-10);
|
||||
--bubble-bot-status-read: 126 182 255;
|
||||
|
||||
--alpha-1: 35, 36, 42, 0.8;
|
||||
--alpha-2: 147, 153, 176, 0.12;
|
||||
--alpha-3: 33, 34, 38, 0.95;
|
||||
@@ -293,4 +380,3 @@
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
}
|
||||
// NEXT COLORS END
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
|
||||
<EmptyStateLayout
|
||||
:title="$t('HELP_CENTER.TITLE')"
|
||||
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
|
||||
class="bg-n-surface-1"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-2 gap-4 p-px">
|
||||
|
||||
+18
-2
@@ -1,8 +1,22 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
{
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -19,6 +33,8 @@ const locales = [
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -17,6 +17,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isDraft: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -37,11 +41,28 @@ const { t } = useI18n();
|
||||
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
||||
|
||||
const localeMenuLabels = computed(() => ({
|
||||
'change-default': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
||||
),
|
||||
'move-to-draft': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
||||
),
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
buildLocaleMenuItems({
|
||||
isDefault: props.isDefault,
|
||||
isDraft: props.isDraft,
|
||||
}).map(item => ({
|
||||
...item,
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
label: localeMenuLabels.value[item.action],
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -56,7 +77,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ locale }} ({{ localeCode }})
|
||||
{{ localeLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
@@ -64,6 +85,12 @@ const handleAction = ({ action, value }) => {
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isDraft"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -86,6 +113,7 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="localeMenuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
|
||||
+48
-2
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -24,12 +24,20 @@ const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
const localeStatus = ref('published');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const draftedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales
|
||||
.filter(locale => locale.draft)
|
||||
.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
@@ -41,17 +49,44 @@ const locales = computed(() => {
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{
|
||||
value: 'published',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
||||
},
|
||||
{
|
||||
value: 'draft',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
localeStatus.value = 'published';
|
||||
};
|
||||
|
||||
watch(localeStatus, value => {
|
||||
if (!value) {
|
||||
localeStatus.value = 'published';
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
const updatedDraftLocales =
|
||||
localeStatus.value === 'draft'
|
||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
||||
: draftedLocales.value;
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: {
|
||||
allowed_locales: updatedLocales,
|
||||
draft_locales: updatedDraftLocales,
|
||||
default_locale: props.portal?.meta?.default_locale,
|
||||
},
|
||||
});
|
||||
@@ -62,7 +97,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
selectedLocale.value = '';
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
@@ -87,6 +122,7 @@ defineExpose({ dialogRef });
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@close="resetForm"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -98,6 +134,16 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="localeStatus"
|
||||
:options="statusOptions"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,6 +29,7 @@ const isLocaleDefault = code => {
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
@@ -39,6 +40,7 @@ const updatePortalLocales = async ({
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
draft_locales: newDraftLocales,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,8 +55,12 @@ const updatePortalLocales = async ({
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const newDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
@@ -81,11 +87,15 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
const updatedDraftLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
newDraftLocales: updatedDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
@@ -98,9 +108,46 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const currentDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
const newDraftLocales = shouldDraft
|
||||
? [...new Set([...currentDraftLocales, localeCode])]
|
||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: props.portal.meta.default_locale,
|
||||
messageKey,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: true,
|
||||
messageKey: 'DRAFT_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const publishLocale = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: false,
|
||||
messageKey: 'PUBLISH_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'move-to-draft') {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -114,6 +161,7 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
|
||||
+12
@@ -4,37 +4,49 @@ import LocalesPage from './LocalesPage.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
code: 'en_GB',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
code: 'ms',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
code: 'ml',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
code: 'hi',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
|
||||
@@ -14,6 +14,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hideToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const modelValue = defineModel({ type: Boolean, default: false });
|
||||
@@ -28,7 +32,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
|
||||
<span class="text-heading-3 text-n-slate-12">
|
||||
{{ header }}
|
||||
</span>
|
||||
<ToggleSwitch v-model="modelValue" />
|
||||
<div v-if="hideToggle" class="size-2" />
|
||||
<ToggleSwitch v-else v-model="modelValue" />
|
||||
</div>
|
||||
<span v-if="description" class="text-body-main text-n-slate-11">
|
||||
{{ description }}
|
||||
|
||||
@@ -80,6 +80,12 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +119,7 @@ const sendMessage = async () => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
+7
@@ -27,6 +27,7 @@ const initialState = {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
citations: false,
|
||||
contactAttributes: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -59,6 +60,7 @@ const updateStateFromAssistant = assistant => {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
citations: config.feature_citation || false,
|
||||
contactAttributes: config.feature_contact_attributes || false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -79,6 +81,7 @@ const handleBasicInfoUpdate = async () => {
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
feature_citation: state.features.citations,
|
||||
feature_contact_attributes: state.features.contactAttributes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -138,6 +141,10 @@ watch(
|
||||
<input v-model="state.features.citations" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.contactAttributes" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONTACT_ATTRIBUTES') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -43,7 +49,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -134,6 +134,11 @@ const props = defineProps({
|
||||
senderId: { type: Number, default: null },
|
||||
senderType: { type: String, default: null },
|
||||
sourceId: { type: String, default: '' }, // eslint-disable-line vue/no-unused-properties
|
||||
forceAlignTo: {
|
||||
type: String,
|
||||
default: null,
|
||||
validator: value => value === null || ['left', 'right'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
@@ -226,12 +231,16 @@ const isBotOrAgentMessage = computed(() => {
|
||||
* @returns {import('vue').ComputedRef<'left'|'right'|'center'>} The computed orientation
|
||||
*/
|
||||
const orientation = computed(() => {
|
||||
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
|
||||
|
||||
if (props.forceAlignTo) {
|
||||
return props.forceAlignTo === 'left' ? ORIENTATION.LEFT : ORIENTATION.RIGHT;
|
||||
}
|
||||
|
||||
if (isBotOrAgentMessage.value) {
|
||||
return ORIENTATION.RIGHT;
|
||||
}
|
||||
|
||||
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return ORIENTATION.CENTER;
|
||||
|
||||
return ORIENTATION.LEFT;
|
||||
});
|
||||
|
||||
@@ -246,6 +255,10 @@ const flexOrientationClass = computed(() => {
|
||||
});
|
||||
|
||||
const gridClass = computed(() => {
|
||||
if (props.forceAlignTo) {
|
||||
return 'grid gap-y-2';
|
||||
}
|
||||
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: 'grid grid-cols-1fr',
|
||||
[ORIENTATION.RIGHT]: 'grid grid-cols-[1fr_24px]',
|
||||
@@ -255,6 +268,14 @@ const gridClass = computed(() => {
|
||||
});
|
||||
|
||||
const gridTemplate = computed(() => {
|
||||
if (props.forceAlignTo) {
|
||||
return `
|
||||
"avatar"
|
||||
"bubble"
|
||||
"meta"
|
||||
`;
|
||||
}
|
||||
|
||||
const map = {
|
||||
[ORIENTATION.LEFT]: `
|
||||
"bubble"
|
||||
@@ -277,11 +298,19 @@ const shouldGroupWithNext = computed(() => {
|
||||
|
||||
const shouldShowAvatar = computed(() => {
|
||||
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
|
||||
if (props.forceAlignTo) return true;
|
||||
if (orientation.value === ORIENTATION.LEFT) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const shouldShowSenderName = computed(() => {
|
||||
if (props.messageType === MESSAGE_TYPES.ACTIVITY) return false;
|
||||
if (!props.forceAlignTo) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const componentToRender = computed(() => {
|
||||
if (props.isEmailInbox && !props.private) {
|
||||
const emailInboxTypes = [MESSAGE_TYPES.INCOMING, MESSAGE_TYPES.OUTGOING];
|
||||
@@ -344,6 +373,8 @@ const componentToRender = computed(() => {
|
||||
});
|
||||
|
||||
const shouldShowContextMenu = computed(() => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_DISABLE_CONTEXT_MENU__) return false;
|
||||
return !props.contentAttributes?.isUnsupported;
|
||||
});
|
||||
|
||||
@@ -410,6 +441,12 @@ const shouldRenderMessage = computed(() => {
|
||||
});
|
||||
|
||||
function openContextMenu(e) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_DISABLE_CONTEXT_MENU__) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSkipContextMenu =
|
||||
e.target?.classList.contains('skip-context-menu') ||
|
||||
['a', 'img'].includes(e.target?.tagName.toLowerCase());
|
||||
@@ -490,7 +527,8 @@ const avatarTooltip = computed(() => {
|
||||
});
|
||||
|
||||
const setupHighlightTimer = () => {
|
||||
if (Number(route.query.messageId) !== Number(props.id)) {
|
||||
// This is a defensive check, since in the ISOLATED shell mode, there might be no route.query
|
||||
if (Number(route?.query?.messageId) !== Number(props.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -548,15 +586,23 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="!shouldGroupWithNext && shouldShowAvatar"
|
||||
v-tooltip.left-end="avatarTooltip"
|
||||
class="[grid-area:avatar] flex items-end"
|
||||
class="[grid-area:avatar] flex items-end gap-2"
|
||||
>
|
||||
<Avatar v-bind="avatarInfo" :size="24" />
|
||||
<span
|
||||
v-if="shouldShowSenderName"
|
||||
class="text-sm font-semibold text-n-slate-12 pb-0.5"
|
||||
>
|
||||
{{ avatarInfo.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="[grid-area:bubble] flex"
|
||||
:class="{
|
||||
'ltr:ml-8 rtl:mr-8 justify-end': orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8': orientation === ORIENTATION.LEFT,
|
||||
'ltr:ml-8 rtl:mr-8 justify-end':
|
||||
!props.forceAlignTo && orientation === ORIENTATION.RIGHT,
|
||||
'ltr:mr-8 rtl:ml-8':
|
||||
!props.forceAlignTo && orientation === ORIENTATION.LEFT,
|
||||
'min-w-0': variant === MESSAGE_VARIANTS.EMAIL,
|
||||
}"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { defineProps, computed, reactive } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { MESSAGE_TYPES } from './constants.js';
|
||||
import shouldGroupWithNext from './groupWithNext.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import MessageApi from 'dashboard/api/inbox/message.js';
|
||||
@@ -90,39 +90,6 @@ const fetchReplyMessage = async (messageId, conversationId) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a message should be grouped with the next message
|
||||
* @param {Number} index - Index of the current message
|
||||
* @param {Array} searchList - Array of messages to check
|
||||
* @returns {Boolean} - Whether the message should be grouped with next
|
||||
*/
|
||||
const shouldGroupWithNext = (index, searchList) => {
|
||||
if (index === searchList.length - 1) return false;
|
||||
|
||||
const current = searchList[index];
|
||||
const next = searchList[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
const hasSameSender = nextSenderId === currentSenderId;
|
||||
|
||||
const nextMessageType = next.messageType;
|
||||
const currentMessageType = current.messageType;
|
||||
|
||||
const areBothTemplates =
|
||||
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
|
||||
currentMessageType === MESSAGE_TYPES.TEMPLATE;
|
||||
|
||||
if (!hasSameSender || areBothTemplates) return false;
|
||||
|
||||
if (currentMessageType !== nextMessageType) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the message that was replied to
|
||||
* @param {Object} parentMessage - The message containing the reply reference
|
||||
|
||||
@@ -9,6 +9,21 @@ import { useMessageContext } from './provider.js';
|
||||
|
||||
import { MESSAGE_STATUS, MESSAGE_TYPES } from './constants';
|
||||
|
||||
const {
|
||||
status,
|
||||
isPrivate,
|
||||
createdAt,
|
||||
sourceId,
|
||||
messageType,
|
||||
contentAttributes,
|
||||
inboxId,
|
||||
} = useMessageContext();
|
||||
|
||||
// Resolve channel type from the message's own inbox so that the status
|
||||
// indicator does not depend on `getSelectedChat` being populated. In the
|
||||
// Agent SDK shell `setActiveChat` runs from a watcher in MessageList and can
|
||||
// race with the first render of MessageMeta, leaving `currentChat` empty and
|
||||
// every channel check returning false (the spinner stays forever).
|
||||
const {
|
||||
isAFacebookInbox,
|
||||
isALineChannel,
|
||||
@@ -21,16 +36,7 @@ const {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
} = useInbox();
|
||||
|
||||
const {
|
||||
status,
|
||||
isPrivate,
|
||||
createdAt,
|
||||
sourceId,
|
||||
messageType,
|
||||
contentAttributes,
|
||||
} = useMessageContext();
|
||||
} = useInbox(inboxId.value);
|
||||
|
||||
const readableTime = computed(() =>
|
||||
messageTimestamp(createdAt.value, 'LLL d, h:mm a')
|
||||
@@ -67,6 +73,9 @@ const isSent = computed(() => {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
|
||||
// API inbox messages use real sent/delivered/read status values from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.SENT;
|
||||
|
||||
// All messages will be mark as sent for the Line channel, as there is no source ID.
|
||||
if (isALineChannel.value) return true;
|
||||
|
||||
@@ -86,8 +95,10 @@ const isDelivered = computed(() => {
|
||||
) {
|
||||
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
|
||||
}
|
||||
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value || isAPIInbox.value) {
|
||||
// API inbox messages use real delivered status from the external system.
|
||||
if (isAPIInbox.value) return status.value === MESSAGE_STATUS.DELIVERED;
|
||||
// All messages marked as delivered for the web widget inbox once they are sent.
|
||||
if (isAWebWidgetInbox.value) {
|
||||
return status.value === MESSAGE_STATUS.SENT;
|
||||
}
|
||||
if (isALineChannel.value) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useIntervalFn } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { MESSAGE_STATUS } from './constants';
|
||||
import { MESSAGE_STATUS, MESSAGE_VARIANTS } from './constants';
|
||||
import { useMessageContext } from './provider.js';
|
||||
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -15,6 +16,19 @@ const { status } = defineProps({
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { variant } = useMessageContext();
|
||||
|
||||
const variantPrefix = computed(() => {
|
||||
const prefixMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'agent',
|
||||
[MESSAGE_VARIANTS.USER]: 'user',
|
||||
[MESSAGE_VARIANTS.PRIVATE]: 'private',
|
||||
[MESSAGE_VARIANTS.BOT]: 'bot',
|
||||
[MESSAGE_VARIANTS.TEMPLATE]: 'bot',
|
||||
[MESSAGE_VARIANTS.EMAIL]: 'agent',
|
||||
};
|
||||
return prefixMap[variant.value] || 'agent';
|
||||
});
|
||||
|
||||
const progresIconSequence = [
|
||||
'i-lucide-clock-1',
|
||||
@@ -54,14 +68,44 @@ const statusIcon = computed(() => {
|
||||
return statusIconMap[status];
|
||||
});
|
||||
|
||||
const statusColor = computed(() => {
|
||||
const statusIconMap = {
|
||||
[MESSAGE_STATUS.SENT]: 'text-n-slate-10',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'text-n-slate-10',
|
||||
[MESSAGE_STATUS.READ]: 'text-[#7EB6FF]',
|
||||
};
|
||||
const statusColorMap = {
|
||||
agent: {
|
||||
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-agent-status))]',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-agent-status))]',
|
||||
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-agent-status-read))]',
|
||||
},
|
||||
user: {
|
||||
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-user-status))]',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-user-status))]',
|
||||
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-user-status-read))]',
|
||||
},
|
||||
private: {
|
||||
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-private-status))]',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-private-status))]',
|
||||
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-private-status-read))]',
|
||||
},
|
||||
bot: {
|
||||
[MESSAGE_STATUS.SENT]: 'text-[rgb(var(--bubble-bot-status))]',
|
||||
[MESSAGE_STATUS.DELIVERED]: 'text-[rgb(var(--bubble-bot-status))]',
|
||||
[MESSAGE_STATUS.READ]: 'text-[rgb(var(--bubble-bot-status-read))]',
|
||||
},
|
||||
};
|
||||
|
||||
return statusIconMap[status];
|
||||
const statusColor = computed(() => {
|
||||
const variantMap =
|
||||
statusColorMap[variantPrefix.value] || statusColorMap.agent;
|
||||
return variantMap[status];
|
||||
});
|
||||
|
||||
const progressColorMap = {
|
||||
agent: 'text-[rgb(var(--bubble-agent-status))]',
|
||||
user: 'text-[rgb(var(--bubble-user-status))]',
|
||||
private: 'text-[rgb(var(--bubble-private-status))]',
|
||||
bot: 'text-[rgb(var(--bubble-bot-status))]',
|
||||
};
|
||||
|
||||
const progressColor = computed(() => {
|
||||
return progressColorMap[variantPrefix.value] || progressColorMap.agent;
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
@@ -81,7 +125,7 @@ const tooltipText = computed(() => {
|
||||
v-if="status === MESSAGE_STATUS.PROGRESS"
|
||||
v-tooltip.top-start="tooltipText"
|
||||
:icon="progessIcon"
|
||||
class="text-n-slate-10"
|
||||
:class="progressColor"
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<svg
|
||||
class="w-8 h-4"
|
||||
viewBox="0 0 120 30"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<circle cx="15" cy="15" r="12">
|
||||
<animate
|
||||
attributeName="cy"
|
||||
from="15"
|
||||
to="5"
|
||||
begin="0s"
|
||||
dur="0.9s"
|
||||
values="15;5;15;15;15;15"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="55" cy="15" r="12">
|
||||
<animate
|
||||
attributeName="cy"
|
||||
from="15"
|
||||
to="5"
|
||||
begin="0.1s"
|
||||
dur="0.9s"
|
||||
values="15;5;15;15;15;15"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="95" cy="15" r="12">
|
||||
<animate
|
||||
attributeName="cy"
|
||||
from="15"
|
||||
to="5"
|
||||
begin="0.2s"
|
||||
dur="0.9s"
|
||||
values="15;5;15;15;15;15"
|
||||
calcMode="linear"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -3,19 +3,31 @@ import { computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import AudioChip from 'next/message/chips/Audio.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_VARIANTS } from '../constants';
|
||||
|
||||
const { attachments } = useMessageContext();
|
||||
const { attachments, variant } = useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const textClass = computed(() => {
|
||||
const textClassMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-text))]',
|
||||
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-text))]',
|
||||
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-text))]',
|
||||
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-text))]',
|
||||
};
|
||||
return textClassMap[variant.value] || 'text-[rgb(var(--bubble-agent-text))]';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="bg-transparent" data-bubble-name="audio">
|
||||
<AudioChip
|
||||
:attachment="attachment"
|
||||
class="p-2 text-n-slate-12 skip-context-menu"
|
||||
class="p-2 skip-context-menu"
|
||||
:class="textClass"
|
||||
/>
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -20,25 +20,30 @@ const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
const { t } = useI18n();
|
||||
|
||||
const varaintBaseMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.AGENT]:
|
||||
'bg-[rgb(var(--bubble-agent-bg))] text-[rgb(var(--bubble-agent-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-agent-border))]',
|
||||
[MESSAGE_VARIANTS.PRIVATE]:
|
||||
'bg-n-solid-amber text-n-amber-12 [&_.prosemirror-mention-node]:font-semibold',
|
||||
[MESSAGE_VARIANTS.USER]: 'bg-n-slate-4 text-n-slate-12',
|
||||
'bg-[rgb(var(--bubble-private-bg))] text-[rgb(var(--bubble-private-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-private-border))] [&_.prosemirror-mention-node]:font-semibold',
|
||||
[MESSAGE_VARIANTS.USER]:
|
||||
'bg-[rgb(var(--bubble-user-bg))] text-[rgb(var(--bubble-user-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-user-border))]',
|
||||
[MESSAGE_VARIANTS.ACTIVITY]: 'bg-n-alpha-1 text-n-slate-11 text-sm',
|
||||
[MESSAGE_VARIANTS.BOT]: 'bg-n-solid-iris text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.TEMPLATE]: 'bg-n-solid-iris text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.ERROR]: 'bg-n-ruby-4 text-n-ruby-12',
|
||||
[MESSAGE_VARIANTS.BOT]:
|
||||
'bg-[rgb(var(--bubble-bot-bg))] text-[rgb(var(--bubble-bot-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-bot-border))]',
|
||||
[MESSAGE_VARIANTS.TEMPLATE]:
|
||||
'bg-[rgb(var(--bubble-bot-bg))] text-[rgb(var(--bubble-bot-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-bot-border))]',
|
||||
[MESSAGE_VARIANTS.ERROR]:
|
||||
'bg-[rgb(var(--bubble-error-bg))] text-[rgb(var(--bubble-error-text))] border-[length:var(--bubble-border-width)] border-[rgb(var(--bubble-error-border))]',
|
||||
[MESSAGE_VARIANTS.EMAIL]: 'w-full',
|
||||
[MESSAGE_VARIANTS.UNSUPPORTED]:
|
||||
'bg-n-solid-amber/70 border border-dashed border-n-amber-12 text-n-amber-12',
|
||||
'bg-[rgb(var(--bubble-unsupported-bg))]/70 border border-dashed border-[rgb(var(--bubble-unsupported-border))] text-[rgb(var(--bubble-unsupported-text))]',
|
||||
};
|
||||
|
||||
const orientationMap = {
|
||||
[ORIENTATION.LEFT]:
|
||||
'left-bubble rounded-xl ltr:rounded-bl-sm rtl:rounded-br-sm',
|
||||
'left-bubble rounded-[var(--bubble-radius)] ltr:rounded-bl-[var(--bubble-radius-sm)] rtl:rounded-br-[var(--bubble-radius-sm)]',
|
||||
[ORIENTATION.RIGHT]:
|
||||
'right-bubble rounded-xl ltr:rounded-br-sm rtl:rounded-bl-sm',
|
||||
[ORIENTATION.CENTER]: 'rounded-md',
|
||||
'right-bubble rounded-[var(--bubble-radius)] ltr:rounded-br-[var(--bubble-radius-sm)] rtl:rounded-bl-[var(--bubble-radius-sm)]',
|
||||
[ORIENTATION.CENTER]: 'rounded-[var(--bubble-radius-md)]',
|
||||
};
|
||||
|
||||
const flexOrientationClass = computed(() => {
|
||||
@@ -57,7 +62,7 @@ const messageClass = computed(() => {
|
||||
if (variant.value !== MESSAGE_VARIANTS.ACTIVITY) {
|
||||
classToApply.push(orientationMap[orientation.value]);
|
||||
} else {
|
||||
classToApply.push('rounded-lg');
|
||||
classToApply.push('rounded-[var(--bubble-radius-lg)]');
|
||||
}
|
||||
|
||||
return classToApply;
|
||||
@@ -76,6 +81,20 @@ const shouldShowMeta = computed(
|
||||
variant.value !== MESSAGE_VARIANTS.ACTIVITY
|
||||
);
|
||||
|
||||
const metaColorClass = computed(() => {
|
||||
const metaClassMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-meta))]',
|
||||
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-meta))]',
|
||||
[MESSAGE_VARIANTS.PRIVATE]:
|
||||
'text-[rgb(var(--bubble-private-meta))] opacity-50',
|
||||
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-meta))]',
|
||||
[MESSAGE_VARIANTS.TEMPLATE]: 'text-[rgb(var(--bubble-bot-meta))]',
|
||||
[MESSAGE_VARIANTS.EMAIL]: 'text-[rgb(var(--bubble-agent-meta))]',
|
||||
[MESSAGE_VARIANTS.ERROR]: 'text-[rgb(var(--bubble-error-meta))]',
|
||||
};
|
||||
return metaClassMap[variant.value] || 'text-[rgb(var(--bubble-agent-meta))]';
|
||||
});
|
||||
|
||||
const replyToPreview = computed(() => {
|
||||
if (!inReplyTo) return '';
|
||||
|
||||
@@ -119,9 +138,7 @@ const replyToPreview = computed(() => {
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
variant === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11',
|
||||
metaColorClass,
|
||||
]"
|
||||
class="mt-2"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { MESSAGE_VARIANTS } from '../constants';
|
||||
|
||||
defineProps({
|
||||
icon: { type: [String, Object], required: true },
|
||||
@@ -20,16 +21,39 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { sender } = useMessageContext();
|
||||
const { sender, variant } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const senderName = computed(() => {
|
||||
return sender?.value?.name || '';
|
||||
});
|
||||
|
||||
const primaryTextClass = computed(() => {
|
||||
const classMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-text))]',
|
||||
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-text))]',
|
||||
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-text))]',
|
||||
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-text))]',
|
||||
};
|
||||
return classMap[variant.value] || 'text-[rgb(var(--bubble-agent-text))]';
|
||||
});
|
||||
|
||||
const secondaryTextClass = computed(() => {
|
||||
const classMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-meta))]',
|
||||
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-meta))]',
|
||||
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-meta))]',
|
||||
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-meta))]',
|
||||
};
|
||||
return classMap[variant.value] || 'text-[rgb(var(--bubble-agent-meta))]';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="overflow-hidden p-3" data-bubble-name="attachment">
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-[var(--bubble-padding-y)]"
|
||||
data-bubble-name="attachment"
|
||||
>
|
||||
<div class="grid gap-4 min-w-64">
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
@@ -41,7 +65,11 @@ const senderName = computed(() => {
|
||||
</slot>
|
||||
</div>
|
||||
<div class="space-y-1 overflow-hidden">
|
||||
<div v-if="senderName" class="text-n-slate-12 text-sm truncate">
|
||||
<div
|
||||
v-if="senderName"
|
||||
class="text-sm truncate"
|
||||
:class="primaryTextClass"
|
||||
>
|
||||
{{
|
||||
t(senderTranslationKey, {
|
||||
sender: senderName,
|
||||
@@ -49,10 +77,18 @@ const senderName = computed(() => {
|
||||
}}
|
||||
</div>
|
||||
<slot>
|
||||
<div v-if="title" class="truncate text-sm text-n-slate-12">
|
||||
<div
|
||||
v-if="title"
|
||||
class="truncate text-sm"
|
||||
:class="primaryTextClass"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div v-if="content" class="truncate text-sm text-n-slate-11">
|
||||
<div
|
||||
v-if="content"
|
||||
class="truncate text-sm"
|
||||
:class="secondaryTextClass"
|
||||
>
|
||||
{{ content }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
@@ -40,7 +40,10 @@ const starRatingValue = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<BaseBubble
|
||||
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
|
||||
data-bubble-name="csat"
|
||||
>
|
||||
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<dl v-if="isRatingSubmitted" class="mt-4">
|
||||
<dt class="text-n-slate-11 italic">
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_STATUS } from '../../constants';
|
||||
import { MESSAGE_STATUS, MESSAGE_TYPES } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
|
||||
const { contentAttributes, status, sender } = useMessageContext();
|
||||
const { contentAttributes, status, sender, messageType } = useMessageContext();
|
||||
|
||||
const hasError = computed(() => {
|
||||
return status.value === MESSAGE_STATUS.FAILED;
|
||||
});
|
||||
|
||||
const isIncoming = computed(() => {
|
||||
return messageType.value === MESSAGE_TYPES.INCOMING;
|
||||
});
|
||||
|
||||
const secondaryTextClass = computed(() => {
|
||||
if (hasError.value) return 'text-[rgb(var(--bubble-error-meta))]';
|
||||
return isIncoming.value
|
||||
? 'text-[rgb(var(--bubble-user-meta))]'
|
||||
: 'text-[rgb(var(--bubble-agent-meta))]';
|
||||
});
|
||||
|
||||
const primaryTextClass = computed(() => {
|
||||
if (hasError.value) return 'text-[rgb(var(--bubble-error-text))]';
|
||||
return isIncoming.value
|
||||
? 'text-[rgb(var(--bubble-user-text))]'
|
||||
: 'text-[rgb(var(--bubble-agent-text))]';
|
||||
});
|
||||
|
||||
const fromEmail = computed(() => {
|
||||
return contentAttributes.value?.email?.from ?? [];
|
||||
});
|
||||
@@ -68,13 +86,10 @@ const showMeta = computed(() => {
|
||||
<section
|
||||
v-show="showMeta"
|
||||
class="space-y-1 rtl:pl-9 ltr:pr-9 text-sm break-words"
|
||||
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-11'"
|
||||
:class="secondaryTextClass"
|
||||
>
|
||||
<template v-if="showMeta">
|
||||
<div
|
||||
v-if="fromEmail[0]"
|
||||
:class="hasError ? 'text-n-ruby-11' : 'text-n-slate-12'"
|
||||
>
|
||||
<div v-if="fromEmail[0]" :class="primaryTextClass">
|
||||
<template v-if="senderName">
|
||||
<span>
|
||||
{{ senderName }}
|
||||
|
||||
@@ -104,21 +104,26 @@ const handleSeeOriginal = () => {
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="w-full"
|
||||
class="w-full border-[length:var(--bubble-border-width)]"
|
||||
:class="{
|
||||
'bg-n-slate-4': isIncoming,
|
||||
'bg-n-solid-blue': isOutgoing,
|
||||
'bg-[rgb(var(--bubble-user-bg))] text-[rgb(var(--bubble-user-text))] border-[rgb(var(--bubble-user-border))]':
|
||||
isIncoming,
|
||||
'bg-[rgb(var(--bubble-agent-bg))] text-[rgb(var(--bubble-agent-text))] border-[rgb(var(--bubble-agent-border))]':
|
||||
isOutgoing,
|
||||
}"
|
||||
data-bubble-name="email"
|
||||
>
|
||||
<EmailMeta
|
||||
class="p-3"
|
||||
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
|
||||
:class="{
|
||||
'border-b border-n-strong': isIncoming,
|
||||
'border-b border-n-slate-8/20': isOutgoing,
|
||||
'border-b border-[rgb(var(--bubble-user-border))]': isIncoming,
|
||||
'border-b border-[rgb(var(--bubble-agent-border))]': isOutgoing,
|
||||
}"
|
||||
/>
|
||||
<section ref="contentContainer" class="p-3">
|
||||
<section
|
||||
ref="contentContainer"
|
||||
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'max-h-[400px] overflow-hidden relative': !isExpanded && isExpandable,
|
||||
@@ -129,14 +134,18 @@ const handleSeeOriginal = () => {
|
||||
v-if="isExpandable && !isExpanded"
|
||||
class="absolute left-0 right-0 bottom-0 h-40 px-8 flex items-end"
|
||||
:class="{
|
||||
'bg-gradient-to-t from-n-slate-4 via-n-slate-4 via-20% to-transparent':
|
||||
'bg-gradient-to-t from-[rgb(var(--bubble-user-bg))] via-[rgb(var(--bubble-user-bg))] via-20% to-transparent':
|
||||
isIncoming,
|
||||
'bg-gradient-to-t from-n-solid-blue via-n-solid-blue via-20% to-transparent':
|
||||
'bg-gradient-to-t from-[rgb(var(--bubble-agent-bg))] via-[rgb(var(--bubble-agent-bg))] via-20% to-transparent':
|
||||
isOutgoing,
|
||||
}"
|
||||
>
|
||||
<button
|
||||
class="text-n-slate-12 py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||
class="py-2 px-8 mx-auto text-center flex items-center gap-2"
|
||||
:class="{
|
||||
'text-[rgb(var(--bubble-user-text))]': isIncoming,
|
||||
'text-[rgb(var(--bubble-agent-text))]': isOutgoing,
|
||||
}"
|
||||
@click="isExpanded = true"
|
||||
>
|
||||
<Icon icon="i-lucide-maximize-2" />
|
||||
@@ -145,7 +154,7 @@ const handleSeeOriginal = () => {
|
||||
</div>
|
||||
<FormattedContent
|
||||
v-if="isOutgoing && content && !hasEmailContent"
|
||||
class="text-n-slate-12"
|
||||
class="text-[rgb(var(--bubble-agent-text))]"
|
||||
:content="messageContent"
|
||||
/>
|
||||
<template v-else>
|
||||
@@ -176,7 +185,11 @@ const handleSeeOriginal = () => {
|
||||
</template>
|
||||
<button
|
||||
v-if="hasQuotedMessage"
|
||||
class="text-n-slate-11 px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
|
||||
class="px-1 leading-none text-sm bg-n-alpha-black2 text-center flex items-center gap-1 mt-2"
|
||||
:class="{
|
||||
'text-[rgb(var(--bubble-user-meta))]': isIncoming,
|
||||
'text-[rgb(var(--bubble-agent-meta))]': isOutgoing,
|
||||
}"
|
||||
@click="showQuotedMessage = !showQuotedMessage"
|
||||
>
|
||||
<template v-if="showQuotedMessage">
|
||||
|
||||
@@ -48,7 +48,10 @@ const formValues = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<BaseBubble
|
||||
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
|
||||
data-bubble-name="form"
|
||||
>
|
||||
<span v-dompurify-html="content" :title="content" />
|
||||
<dl v-if="formValues.length" class="mt-4">
|
||||
<template v-for="item in formValues" :key="item.title">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLoadWithRetry } from 'dashboard/composables/loadWithRetry';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useGallery } from '../useGallery';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
@@ -22,8 +23,8 @@ const attachment = computed(() => {
|
||||
|
||||
const { isLoaded, hasError, loadWithRetry } = useLoadWithRetry();
|
||||
|
||||
const showGallery = ref(false);
|
||||
const isDownloading = ref(false);
|
||||
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
|
||||
|
||||
onMounted(() => {
|
||||
if (attachment.value?.dataUrl) {
|
||||
@@ -50,13 +51,16 @@ const handleImageError = () => {
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3"
|
||||
class="overflow-hidden p-[var(--bubble-padding-y)]"
|
||||
data-bubble-name="image"
|
||||
@click="showGallery = true"
|
||||
@click="toggleGallery(true)"
|
||||
>
|
||||
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11">
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="flex items-center gap-1 text-center rounded-lg text-[rgb(var(--bubble-error-meta))]"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-off" />
|
||||
<p class="mb-0">
|
||||
{{ $t('COMPONENTS.MEDIA.IMAGE_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,7 +90,7 @@ const handleImageError = () => {
|
||||
</div>
|
||||
</BaseBubble>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-if="showGallery && isGalleryAllowed"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
|
||||
@@ -44,7 +44,10 @@ const onVideoLoadError = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
|
||||
<BaseBubble
|
||||
class="p-[var(--bubble-padding-y)] overflow-hidden"
|
||||
data-bubble-name="ig-story"
|
||||
>
|
||||
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
|
||||
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
|
||||
</p>
|
||||
@@ -64,10 +67,10 @@ const onVideoLoadError = () => {
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1"
|
||||
class="flex items-center gap-1 px-5 py-4 text-center rounded-lg bg-n-alpha-1 text-[rgb(var(--bubble-error-meta))]"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||
<p class="mb-0 text-n-slate-11">
|
||||
<Icon icon="i-lucide-circle-off" />
|
||||
<p class="mb-0">
|
||||
{{ $t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_UNAVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -4,11 +4,11 @@ import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import FormattedContent from './FormattedContent.vue';
|
||||
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
|
||||
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
|
||||
import { MESSAGE_TYPES } from '../../constants';
|
||||
import { MESSAGE_TYPES, MESSAGE_VARIANTS } from '../../constants';
|
||||
import { useMessageContext } from '../../provider.js';
|
||||
import { useTranslations } from 'dashboard/composables/useTranslations';
|
||||
|
||||
const { content, attachments, contentAttributes, messageType } =
|
||||
const { content, attachments, contentAttributes, messageType, variant } =
|
||||
useMessageContext();
|
||||
|
||||
const { hasTranslations, translationContent } =
|
||||
@@ -36,15 +36,29 @@ const isEmpty = computed(() => {
|
||||
return !content.value && !attachments.value?.length;
|
||||
});
|
||||
|
||||
const emptyTextClass = computed(() => {
|
||||
const metaClassMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'text-[rgb(var(--bubble-agent-meta))]',
|
||||
[MESSAGE_VARIANTS.USER]: 'text-[rgb(var(--bubble-user-meta))]',
|
||||
[MESSAGE_VARIANTS.PRIVATE]: 'text-[rgb(var(--bubble-private-meta))]',
|
||||
[MESSAGE_VARIANTS.BOT]: 'text-[rgb(var(--bubble-bot-meta))]',
|
||||
[MESSAGE_VARIANTS.TEMPLATE]: 'text-[rgb(var(--bubble-bot-meta))]',
|
||||
};
|
||||
return metaClassMap[variant.value] || 'text-[rgb(var(--bubble-agent-meta))]';
|
||||
});
|
||||
|
||||
const handleSeeOriginal = () => {
|
||||
renderOriginal.value = !renderOriginal.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="text">
|
||||
<BaseBubble
|
||||
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)]"
|
||||
data-bubble-name="text"
|
||||
>
|
||||
<div class="gap-3 flex flex-col">
|
||||
<span v-if="isEmpty" class="text-n-slate-11">
|
||||
<span v-if="isEmpty" :class="emptyTextClass">
|
||||
{{ $t('CONVERSATION.NO_CONTENT') }}
|
||||
</span>
|
||||
<FormattedContent v-if="renderContent" :content="renderContent" />
|
||||
|
||||
@@ -21,7 +21,10 @@ const unsupportedMessageKey = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
|
||||
<BaseBubble
|
||||
class="px-[var(--bubble-padding-x)] py-[var(--bubble-padding-y)] text-sm"
|
||||
data-bubble-name="unsupported"
|
||||
>
|
||||
{{ $t(unsupportedMessageKey) }}
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import BaseBubble from './Base.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useGallery } from '../useGallery';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
@@ -9,7 +10,7 @@ import { ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const hasError = ref(false);
|
||||
const showGallery = ref(false);
|
||||
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
|
||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||
|
||||
const handleError = () => {
|
||||
@@ -28,9 +29,9 @@ const isReel = computed(() => {
|
||||
|
||||
<template>
|
||||
<BaseBubble
|
||||
class="overflow-hidden p-3"
|
||||
class="overflow-hidden p-[var(--bubble-padding-y)]"
|
||||
data-bubble-name="video"
|
||||
@click="showGallery = true"
|
||||
@click="toggleGallery(true)"
|
||||
>
|
||||
<div class="relative group rounded-lg overflow-hidden">
|
||||
<div
|
||||
@@ -53,7 +54,7 @@ const isReel = computed(() => {
|
||||
</div>
|
||||
</BaseBubble>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-if="showGallery && isGalleryAllowed"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useGallery } from '../useGallery';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
|
||||
@@ -13,7 +14,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
const hasError = ref(false);
|
||||
const showGallery = ref(false);
|
||||
const { showGallery, toggleGallery, isGalleryAllowed } = useGallery();
|
||||
|
||||
const { filteredCurrentChatAttachments } = useMessageContext();
|
||||
|
||||
@@ -25,7 +26,7 @@ const handleError = () => {
|
||||
<template>
|
||||
<div
|
||||
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
|
||||
@click="showGallery = true"
|
||||
@click="toggleGallery(true)"
|
||||
>
|
||||
<div
|
||||
v-if="hasError"
|
||||
@@ -42,7 +43,7 @@ const handleError = () => {
|
||||
/>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-if="showGallery && isGalleryAllowed"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import { useGallery } from '../useGallery';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||
@@ -12,7 +12,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const showGallery = ref(false);
|
||||
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
|
||||
|
||||
const { filteredCurrentChatAttachments } = useMessageContext();
|
||||
</script>
|
||||
@@ -20,7 +20,7 @@ const { filteredCurrentChatAttachments } = useMessageContext();
|
||||
<template>
|
||||
<div
|
||||
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
|
||||
@click="showGallery = true"
|
||||
@click="toggleGallery(true)"
|
||||
>
|
||||
<video
|
||||
:src="attachment.dataUrl"
|
||||
@@ -42,7 +42,7 @@ const { filteredCurrentChatAttachments } = useMessageContext();
|
||||
</div>
|
||||
</div>
|
||||
<GalleryView
|
||||
v-if="showGallery"
|
||||
v-if="showGallery && isGalleryAllowed"
|
||||
v-model:show="showGallery"
|
||||
:attachment="useSnakeCase(attachment)"
|
||||
:all-attachments="filteredCurrentChatAttachments"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MESSAGE_TYPES } from './constants';
|
||||
/**
|
||||
* Determines if a message should be grouped with the next message
|
||||
* @param {Number} index - Index of the current message
|
||||
* @param {Array} searchList - Array of messages to check
|
||||
* @returns {Boolean} - Whether the message should be grouped with next
|
||||
*/
|
||||
export default (index, searchList) => {
|
||||
if (index === searchList.length - 1) return false;
|
||||
|
||||
const current = searchList[index];
|
||||
const next = searchList[index + 1];
|
||||
|
||||
if (next.status === 'failed') return false;
|
||||
|
||||
const nextSenderId = next.senderId ?? next.sender?.id;
|
||||
const currentSenderId = current.senderId ?? current.sender?.id;
|
||||
const hasSameSender = nextSenderId === currentSenderId;
|
||||
|
||||
const nextMessageType = next.messageType;
|
||||
const currentMessageType = current.messageType;
|
||||
|
||||
const areBothTemplates =
|
||||
nextMessageType === MESSAGE_TYPES.TEMPLATE &&
|
||||
currentMessageType === MESSAGE_TYPES.TEMPLATE;
|
||||
|
||||
if (!hasSameSender || areBothTemplates) return false;
|
||||
|
||||
if (currentMessageType !== nextMessageType) return false;
|
||||
|
||||
// Check if messages are in the same minute by rounding down to nearest minute
|
||||
return Math.floor(next.createdAt / 60) === Math.floor(current.createdAt / 60);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { computed } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
export function useGallery() {
|
||||
const [showGallery] = useToggle(false);
|
||||
|
||||
const isGalleryAllowed = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
const toggleGallery = value => {
|
||||
showGallery.value = value;
|
||||
};
|
||||
|
||||
return {
|
||||
showGallery,
|
||||
isGalleryAllowed,
|
||||
toggleGallery,
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const { modalType, closeOnBackdropClick, onClose } = defineProps({
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const show = defineModel('show', { type: Boolean, default: false });
|
||||
const teleportTarget = ref(null);
|
||||
|
||||
const modalClassName = computed(() => {
|
||||
const modalClassNameMap = {
|
||||
@@ -25,6 +26,24 @@ const modalClassName = computed(() => {
|
||||
return `modal-mask skip-context-menu ${modalClassNameMap[modalType] || ''}`;
|
||||
});
|
||||
|
||||
const resolveTeleportTarget = () => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_ISOLATED_SHELL__) {
|
||||
// Look for the specific chatwoot-message-list custom element
|
||||
const messageListElement = document.querySelector('chatwoot-message-list');
|
||||
if (messageListElement?.shadowRoot) {
|
||||
const modalRoot =
|
||||
messageListElement.shadowRoot.querySelector('#cw-modal-root');
|
||||
if (modalRoot) {
|
||||
teleportTarget.value = modalRoot;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
teleportTarget.value = document.body;
|
||||
};
|
||||
|
||||
// [TODO] Revisit this logic to use outside click directive
|
||||
const mousedDownOnBackdrop = ref(false);
|
||||
|
||||
@@ -58,6 +77,7 @@ useEventListener(document.body, 'mouseup', onMouseUp);
|
||||
useEventListener(document, 'keydown', onKeydown);
|
||||
|
||||
onMounted(() => {
|
||||
resolveTeleportTarget();
|
||||
if (import.meta.env.DEV && onClose && typeof onClose === 'function') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
@@ -68,36 +88,38 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-if="show"
|
||||
:class="modalClassName"
|
||||
transition="modal"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<Teleport v-if="teleportTarget" :to="teleportTarget">
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
class="relative max-h-full overflow-auto bg-n-alpha-3 shadow-md modal-container rtl:text-right skip-context-menu"
|
||||
:class="{
|
||||
'rounded-xl w-[37.5rem]': !fullWidth,
|
||||
'items-center rounded-none flex h-full justify-center w-full':
|
||||
fullWidth,
|
||||
[size]: true,
|
||||
}"
|
||||
@mouse.stop
|
||||
@mousedown="event => event.stopPropagation()"
|
||||
v-if="show"
|
||||
:class="modalClassName"
|
||||
transition="modal"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
<Button
|
||||
v-if="showCloseButton"
|
||||
ghost
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
|
||||
@click="close"
|
||||
/>
|
||||
<slot />
|
||||
<div
|
||||
class="relative max-h-full overflow-auto bg-n-alpha-3 shadow-md modal-container rtl:text-right skip-context-menu"
|
||||
:class="{
|
||||
'rounded-xl w-[37.5rem]': !fullWidth,
|
||||
'items-center rounded-none flex h-full justify-center w-full':
|
||||
fullWidth,
|
||||
[size]: true,
|
||||
}"
|
||||
@mouse.stop
|
||||
@mousedown="event => event.stopPropagation()"
|
||||
>
|
||||
<Button
|
||||
v-if="showCloseButton"
|
||||
ghost
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
|
||||
@click="close"
|
||||
/>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -89,6 +89,7 @@ const props = defineProps({
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
forceSignature: { type: Boolean, default: false },
|
||||
conversationId: { type: Number, default: null },
|
||||
medium: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
@@ -313,6 +314,10 @@ const plugins = computed(() => {
|
||||
const sendWithSignature = computed(() => {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
if (props.forceSignature && props.allowSignature && !props.isPrivate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
}
|
||||
@@ -564,10 +569,14 @@ function updateImgToolbarOnDelete() {
|
||||
}
|
||||
|
||||
function isEnterToSendEnabled() {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_ISOLATED_SHELL__) return false;
|
||||
return isEditorHotKeyEnabled('enter');
|
||||
}
|
||||
|
||||
function isCmdPlusEnterToSendEnabled() {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__WOOT_ISOLATED_SHELL__) return true;
|
||||
return isEditorHotKeyEnabled('cmd_enter');
|
||||
}
|
||||
|
||||
@@ -811,7 +820,7 @@ watch(sendWithSignature, newValue => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
// [VITE] state assignment was done in created before
|
||||
state = createState(
|
||||
props.modelValue,
|
||||
@@ -823,11 +832,16 @@ onMounted(() => {
|
||||
|
||||
createEditorView();
|
||||
editorView.updateState(state);
|
||||
if (props.focusOnMount) {
|
||||
// TODO: test this in the main app
|
||||
if (sendWithSignature.value) {
|
||||
addSignature();
|
||||
} else if (props.focusOnMount) {
|
||||
focusEditorInputField();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ focusEditorInputField });
|
||||
|
||||
// BUS Event to insert text or markdown into the editor at the
|
||||
// current cursor position.
|
||||
// Components using this
|
||||
@@ -915,6 +929,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
.ProseMirror-menubar-wrapper {
|
||||
@apply flex flex-col gap-3;
|
||||
|
||||
.ProseMirror-menubar:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
min-height: 1.25rem !important;
|
||||
@apply items-center gap-4 flex pb-0 bg-transparent text-n-slate-11 relative ltr:-left-[3px] rtl:-right-[3px];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -12,6 +13,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
iconOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -69,22 +74,34 @@ const translateValue = computed(() => {
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||
class="flex items-center w-auto p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||
:disabled="disabled || isReplyRestricted"
|
||||
:class="{
|
||||
'h-7': iconOnly,
|
||||
'h-8': !iconOnly,
|
||||
'cursor-not-allowed': disabled || isReplyRestricted,
|
||||
}"
|
||||
@click="$emit('toggleMode')"
|
||||
>
|
||||
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
|
||||
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
|
||||
<div
|
||||
ref="wootEditorReplyMode"
|
||||
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
|
||||
>
|
||||
<Icon v-if="iconOnly" icon="i-lucide-message-circle" class="size-4" />
|
||||
<template v-else>{{ $t('CONVERSATION.REPLYBOX.REPLY') }}</template>
|
||||
</div>
|
||||
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
|
||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||
<div
|
||||
ref="wootEditorPrivateMode"
|
||||
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
|
||||
>
|
||||
<Icon v-if="iconOnly" icon="i-lucide-lock-keyhole" class="size-4" />
|
||||
<template v-else>{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}</template>
|
||||
</div>
|
||||
<div
|
||||
class="absolute shadow-sm rounded-full h-6 w-[var(--chip-width)] ease-in-out translate-x-[var(--translate-x)] rtl:translate-x-[var(--rtl-translate-x)] bg-n-solid-1"
|
||||
:class="{
|
||||
'h-5': iconOnly,
|
||||
'h-6': !iconOnly,
|
||||
'transition-all duration-300': !disabled && !isReplyRestricted,
|
||||
}"
|
||||
:style="{
|
||||
|
||||
@@ -114,6 +114,11 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
allowEmoji: { type: Boolean, default: false },
|
||||
allowVideoCall: { type: Boolean, default: false },
|
||||
allowFileUpload: { type: Boolean, default: false },
|
||||
allowAudioRecorder: { type: Boolean, default: false },
|
||||
showQuotedReplyToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -291,7 +296,7 @@ export default {
|
||||
<div class="flex justify-between p-3" :class="wrapClass">
|
||||
<div class="left-wrap">
|
||||
<NextButton
|
||||
v-if="!isEditorDisabled"
|
||||
v-if="allowEmoji && !isEditorDisabled"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
icon="i-ph-smiley-sticker"
|
||||
slate
|
||||
@@ -300,7 +305,7 @@ export default {
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<FileUpload
|
||||
v-if="showAttachButton"
|
||||
v-if="showAttachButton && allowFileUpload"
|
||||
ref="uploadRef"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
input-id="conversationAttachment"
|
||||
@@ -325,7 +330,7 @@ export default {
|
||||
/>
|
||||
</FileUpload>
|
||||
<NextButton
|
||||
v-if="showAudioRecorderButton"
|
||||
v-if="allowAudioRecorder && showAudioRecorderButton"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
|
||||
slate
|
||||
@@ -334,7 +339,7 @@ export default {
|
||||
@click="toggleAudioRecorder"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showAudioPlayStopButton"
|
||||
v-if="allowAudioRecorder && showAudioPlayStopButton"
|
||||
:icon="audioRecorderPlayStopIcon"
|
||||
slate
|
||||
faded
|
||||
@@ -343,7 +348,7 @@ export default {
|
||||
@click="toggleAudioRecorderPlayPause"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="showMessageSignatureButton"
|
||||
v-if="showMessageSignatureButton && allowSignature"
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="i-ph-signature"
|
||||
slate
|
||||
@@ -381,6 +386,7 @@ export default {
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="
|
||||
allowVideoCall &&
|
||||
(isAWebWidgetInbox || isAPIInbox) &&
|
||||
!isOnPrivateNote &&
|
||||
!isEditorDisabled
|
||||
|
||||
@@ -25,6 +25,10 @@ export default {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
disablePopout: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isReplyRestricted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
} = useUISettings();
|
||||
|
||||
const replyEditor = useTemplateRef('replyEditor');
|
||||
const messageEditor = useTemplateRef('messageEditor');
|
||||
const copilot = useCopilotReply();
|
||||
const shortcutKey = useKbd(['$mod', '+', 'enter']);
|
||||
|
||||
@@ -109,6 +110,7 @@ export default {
|
||||
setQuotedReplyFlagForInbox,
|
||||
fetchQuotedReplyFlagFromUISettings,
|
||||
replyEditor,
|
||||
messageEditor,
|
||||
copilot,
|
||||
shortcutKey,
|
||||
};
|
||||
@@ -507,7 +509,7 @@ export default {
|
||||
);
|
||||
|
||||
this.fetchAndSetReplyTo();
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
|
||||
// A hacky fix to solve the drag and drop
|
||||
// Is showing on top of new conversation modal drag and drop
|
||||
@@ -522,7 +524,7 @@ export default {
|
||||
unmounted() {
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.onReplyToMessage);
|
||||
emitter.off(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
||||
emitter.off(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
@@ -1191,6 +1193,15 @@ export default {
|
||||
return false;
|
||||
});
|
||||
},
|
||||
onReplyToMessage() {
|
||||
this.fetchAndSetReplyTo();
|
||||
if (this.inReplyTo) {
|
||||
this.$nextTick(() => {
|
||||
const pos = this.isSignatureEnabledForInbox ? 'start' : 'end';
|
||||
this.messageEditor?.focusEditorInputField(pos);
|
||||
});
|
||||
}
|
||||
},
|
||||
resetReplyToMessage() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
@@ -1313,6 +1324,7 @@ export default {
|
||||
/>
|
||||
<WootMessageEditor
|
||||
v-else-if="!showAudioRecorderEditor"
|
||||
ref="messageEditor"
|
||||
v-model="message"
|
||||
:conversation-id="conversationId"
|
||||
:editor-id="editorStateId"
|
||||
|
||||
@@ -32,6 +32,7 @@ const emit = defineEmits(['dismiss']);
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="flex-shrink-0"
|
||||
@click.stop="emit('dismiss')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useImageZoom } from 'dashboard/composables/useImageZoom';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { downloadFile } from '@chatwoot/utils';
|
||||
|
||||
import WootModal from 'dashboard/components/Modal.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
@@ -168,7 +169,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection to="body">
|
||||
<woot-modal
|
||||
<WootModal
|
||||
v-model:show="show"
|
||||
full-width
|
||||
:show-close-button="false"
|
||||
@@ -211,7 +212,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
|
||||
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12 hidden sm:block"
|
||||
>
|
||||
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
|
||||
</div>
|
||||
@@ -353,6 +354,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</WootModal>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,12 @@ import { computed, unref } from 'vue';
|
||||
import { getCurrentInstance } from 'vue';
|
||||
|
||||
export const useStore = () => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (window.__CHATWOOT_STORE__) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
return window.__CHATWOOT_STORE__;
|
||||
}
|
||||
|
||||
const vm = getCurrentInstance();
|
||||
if (!vm) throw new Error('must be called in setup');
|
||||
return vm.proxy.$store;
|
||||
|
||||
@@ -11,7 +11,12 @@ export function useAccount() {
|
||||
* Computed property for the current account ID.
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
const route = useRoute();
|
||||
// In the isolated shell (react-components / Web Component context),
|
||||
// vue-router is not available. Fall back to the global variable.
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const isIsolatedShell = !!window.__WOOT_ISOLATED_SHELL__;
|
||||
const route = isIsolatedShell ? null : useRoute();
|
||||
|
||||
const store = useStore();
|
||||
const getAccountFn = useMapGetter('accounts/getAccount');
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
@@ -20,6 +25,10 @@ export function useAccount() {
|
||||
);
|
||||
|
||||
const accountId = computed(() => {
|
||||
if (isIsolatedShell) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
return Number(window.__WOOT_ACCOUNT_ID__);
|
||||
}
|
||||
return Number(route.params.accountId);
|
||||
});
|
||||
const currentAccount = computed(() => getAccountFn.value(accountId.value));
|
||||
|
||||
@@ -10,7 +10,10 @@ const { isImpersonating } = useImpersonation();
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
const { websocketURL = '' } = window.chatwootConfig || {};
|
||||
super(app, pubsubToken, websocketURL);
|
||||
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const wsURL = websocketURL || window.__WEBSOCKET_URL__ || '';
|
||||
super(app, pubsubToken, wsURL);
|
||||
this.CancelTyping = [];
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
@@ -98,7 +101,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
conversation: { last_activity_at: lastActivityAt },
|
||||
conversation_id: conversationId,
|
||||
} = data;
|
||||
DashboardAudioNotificationHelper.onNewMessage(data);
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
if (!window.__WOOT_ISOLATED_SHELL__) {
|
||||
DashboardAudioNotificationHelper.onNewMessage(data);
|
||||
}
|
||||
this.app.$store.dispatch('addMessage', data);
|
||||
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||
lastActivityAt,
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
/* eslint no-param-reassign: 0 */
|
||||
|
||||
import getUuid from 'widget/helpers/uuid';
|
||||
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
|
||||
export default () => {
|
||||
if (!Array.prototype.last) {
|
||||
Object.assign(Array.prototype, {
|
||||
last() {
|
||||
return this[this.length - 1];
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const isEmptyObject = obj =>
|
||||
Object.keys(obj).length === 0 && obj.constructor === Object;
|
||||
|
||||
|
||||
@@ -133,20 +133,55 @@ export const ARTICLE_TABS_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCALE_MENU_ITEMS = [
|
||||
{
|
||||
export const LOCALE_MENU_ITEMS = {
|
||||
makeDefault: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
|
||||
action: 'change-default',
|
||||
value: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
},
|
||||
{
|
||||
moveToDraft: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
|
||||
action: 'move-to-draft',
|
||||
value: 'draft',
|
||||
icon: 'i-lucide-eye-off',
|
||||
},
|
||||
publishLocale: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
|
||||
action: 'publish-locale',
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const disableLocaleMenuItems = menuItems =>
|
||||
menuItems.map(item => ({ ...item, disabled: true }));
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
|
||||
published: ['archive', 'draft'],
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
|
||||
import {
|
||||
buildLocaleMenuItems,
|
||||
buildPortalArticleURL,
|
||||
buildPortalURL,
|
||||
} from '../portalHelper';
|
||||
|
||||
describe('PortalHelper', () => {
|
||||
describe('buildPortalURL', () => {
|
||||
@@ -68,4 +72,39 @@ describe('PortalHelper', () => {
|
||||
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
})
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: 'change-default', disabled: true }),
|
||||
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
|
||||
expect.objectContaining({ action: 'delete', disabled: true }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"LOADING_EDITOR": "Loading editor...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"COUNT": "{n} bot | {n} bots",
|
||||
"SEARCH_PLACEHOLDER": "Search bots...",
|
||||
"NO_RESULTS": "No bots found matching your search",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "System",
|
||||
"AVATAR": {
|
||||
@@ -34,7 +37,8 @@
|
||||
"LOADING": "Fetching bots...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "Webhook URL"
|
||||
"URL": "Webhook URL",
|
||||
"ACTIONS": "Actions"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ADMINISTRATOR": "Administrator",
|
||||
"AGENT": "Agent"
|
||||
},
|
||||
"COUNT": "{n} agent | {n} agents",
|
||||
"LIST": {
|
||||
"404": "There are no agents associated to this account",
|
||||
"TITLE": "Manage agents in your team",
|
||||
@@ -96,6 +97,8 @@
|
||||
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search agents...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "No results found."
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
@@ -63,6 +66,10 @@
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "Enable regex validation"
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Automation",
|
||||
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
|
||||
"LEARN_MORE": "Learn more about automation",
|
||||
"HEADER_BTN_TXT": "Add Automation Rule",
|
||||
"COUNT": "{n} automation | {n} automations",
|
||||
"HEADER_BTN_TXT": "Create Automation",
|
||||
"LOADING": "Fetching automation rules",
|
||||
"SEARCH_PLACEHOLDER": "Search automation rules...",
|
||||
"NO_RESULTS": "No automation rules found matching your search",
|
||||
"ADD": {
|
||||
"TITLE": "Add Automation Rule",
|
||||
"SUBMIT": "Create",
|
||||
@@ -42,9 +45,9 @@
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"ACTIVE": "Active",
|
||||
"CREATED_ON": "Created on"
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found"
|
||||
},
|
||||
@@ -150,7 +153,8 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
"OPEN_CONVERSATION": "Open conversation",
|
||||
"PENDING_CONVERSATION": "Mark conversation as pending"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
|
||||
"UPDATE_FAILED": "Failed to update conversations. Please try again."
|
||||
},
|
||||
"RESOLVE": {
|
||||
"ALL_MISSING_ATTRIBUTES": "Cannot resolve conversations due to missing required attributes",
|
||||
"PARTIAL_SUCCESS": "Some conversations need required attributes before resolving and were skipped"
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Canned Responses",
|
||||
"LEARN_MORE": "Learn more about canned responses",
|
||||
"DESCRIPTION": "Canned Responses are pre-written reply templates that help you quickly respond to a conversation. Agents can type the '/' character followed by the shortcode to insert a canned response during a conversation. ",
|
||||
"COUNT": "{n} canned response | {n} canned responses",
|
||||
"HEADER_BTN_TXT": "Add canned response",
|
||||
"LOADING": "Fetching canned responses...",
|
||||
"SEARCH_PLACEHOLDER": "Search canned responses...",
|
||||
"NO_RESULTS": "No canned responses found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"LIST": {
|
||||
"404": "There are no canned responses available in this account.",
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "Pending Response: Shortest first"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
|
||||
@@ -457,6 +457,9 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"PLACEHOLDER": "Add Telegram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
},
|
||||
@@ -573,7 +576,8 @@
|
||||
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
|
||||
}
|
||||
},
|
||||
"LOAD_MORE": "Load more"
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
@@ -607,7 +611,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"LABEL": "To:",
|
||||
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
|
||||
"CONTACT_CREATING": "Creating contact..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -618,9 +622,9 @@
|
||||
"SUBJECT_LABEL": "Subject :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"CC_LABEL": "Cc:",
|
||||
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_LABEL": "Bcc:",
|
||||
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_BUTTON": "Bcc"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -173,6 +174,10 @@
|
||||
"SUCCESFUL": "Assigned label #{labelName} to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign label. Please try again."
|
||||
},
|
||||
"LABEL_REMOVAL": {
|
||||
"SUCCESFUL": "Removed label #{labelName} from conversation id {conversationId}",
|
||||
"FAILED": "Couldn't remove label. Please try again."
|
||||
},
|
||||
"TEAM_ASSIGNMENT": {
|
||||
"SUCCESFUL": "Assigned team \"{team}\" to conversation id {conversationId}",
|
||||
"FAILED": "Couldn't assign team. Please try again."
|
||||
@@ -185,7 +190,11 @@
|
||||
"DISABLE_SIGN_TOOLTIP": "Disable signature",
|
||||
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
|
||||
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
|
||||
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
|
||||
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
|
||||
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
|
||||
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
|
||||
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
|
||||
"CLICK_HERE": "Click here to update",
|
||||
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
|
||||
},
|
||||
@@ -205,7 +214,7 @@
|
||||
"DRAG_DROP": "Drag and drop here to attach",
|
||||
"START_AUDIO_RECORDING": "Start audio recording",
|
||||
"STOP_AUDIO_RECORDING": "Stop audio recording",
|
||||
"": "",
|
||||
"COPILOT_THINKING": "Copilot is thinking",
|
||||
"EMAIL_HEAD": {
|
||||
"TO": "TO",
|
||||
"ADD_BCC": "Add bcc",
|
||||
@@ -247,9 +256,12 @@
|
||||
"SUCCESS_DELETE_CONVERSATION": "ውይይት በተሳካ ሁኔታ ተሰርዟል",
|
||||
"FAIL_DELETE_CONVERSATION": "ውይይትን መሰረዝ አልተቻለም! እንደገና ይሞክሩ",
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
|
||||
"FILE_TYPE_NOT_SUPPORTED": "This {fileName} file type is not supported in this conversation",
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
"BOT": "Bot",
|
||||
"NATIVE_APP": "Native app",
|
||||
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
|
||||
"SEND_FAILED": "Couldn't send message! Try again",
|
||||
"TRY_AGAIN": "retry",
|
||||
"ASSIGNMENT": {
|
||||
@@ -294,6 +306,7 @@
|
||||
"CANCEL": "Cancel",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again",
|
||||
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
|
||||
"FORM": {
|
||||
"SEND_TO_CONTACT": "Send the transcript to the customer",
|
||||
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "Custom Roles",
|
||||
"LEARN_MORE": "Learn more about custom roles",
|
||||
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"COUNT": "{n} custom role | {n} custom roles",
|
||||
"HEADER_BTN_TXT": "Add custom role",
|
||||
"LOADING": "Fetching custom roles...",
|
||||
"SEARCH_PLACEHOLDER": "Search custom roles...",
|
||||
"NO_RESULTS": "No custom roles found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create custom roles",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"DATE_PICKER": {
|
||||
"PREVIOUS_PERIOD": "Previous period",
|
||||
"NEXT_PERIOD": "Next period",
|
||||
"WEEK_NUMBER": "Week #{weekNumber}",
|
||||
"APPLY_BUTTON": "Apply",
|
||||
"CLEAR_BUTTON": "Clear",
|
||||
"DATE_RANGE_INPUT": {
|
||||
@@ -13,6 +16,8 @@
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
"LAST_YEAR": "Last year",
|
||||
"THIS_WEEK": "This week",
|
||||
"MONTH_TO_DATE": "This month",
|
||||
"CUSTOM_RANGE": "Custom date range"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
},
|
||||
"CLOSE": "Close",
|
||||
"BETA": "Beta",
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
|
||||
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
|
||||
"ACCEPT": "Accept",
|
||||
"DISCARD": "Discard",
|
||||
"PREFERRED": "Preferred"
|
||||
},
|
||||
"CHOICE_TOGGLE": {
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
},
|
||||
"COMMAND_BAR": {
|
||||
"SEARCH_PLACEHOLDER": "Search or jump to",
|
||||
"SNOOZE_PLACEHOLDER": "Type a time e.g. tomorrow, 2 hours, next friday, jan 15...",
|
||||
"SECTIONS": {
|
||||
"GENERAL": "General",
|
||||
"REPORTS": "Reports",
|
||||
|
||||
@@ -374,6 +374,16 @@
|
||||
"ERROR_MESSAGE": "Error while deleting article"
|
||||
}
|
||||
},
|
||||
"REORDER_ARTICLE": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder articles. Please try again."
|
||||
}
|
||||
},
|
||||
"REORDER_CATEGORY": {
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Unable to reorder categories. Please try again."
|
||||
}
|
||||
},
|
||||
"CREATE_ARTICLE": {
|
||||
"ERROR_MESSAGE": "Please add the article heading and content then only you can update the settings"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"HEADER": "Inboxes",
|
||||
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
|
||||
"LEARN_MORE": "Learn more about inboxes",
|
||||
"COUNT": "{n} inbox | {n} inboxes",
|
||||
"SEARCH_PLACEHOLDER": "Search inboxes...",
|
||||
"NO_RESULTS": "No inboxes found matching your search",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
|
||||
@@ -575,7 +578,7 @@
|
||||
"SUBTITLE": "Use only the configured business name as the sender name in the email header."
|
||||
},
|
||||
"BUSINESS_NAME": {
|
||||
"BUTTON_TEXT": "+ Configure your business name",
|
||||
"BUTTON_TEXT": "Configure your business name",
|
||||
"PLACEHOLDER": "Enter your business name",
|
||||
"SAVE_BUTTON_TEXT": "Save"
|
||||
}
|
||||
@@ -589,8 +592,10 @@
|
||||
"DISABLED": "Disabled"
|
||||
},
|
||||
"LOCK_TO_SINGLE_CONVERSATION": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
"ENABLED": "Reopen same conversation",
|
||||
"DISABLED": "Create new conversations",
|
||||
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
|
||||
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
|
||||
},
|
||||
"ENABLE_HMAC": {
|
||||
"LABEL": "Enable"
|
||||
@@ -625,6 +630,8 @@
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "CSAT"
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
"ACCOUNT_HEALTH": {
|
||||
"TITLE": "Manage your WhatsApp account",
|
||||
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
|
||||
@@ -678,6 +685,16 @@
|
||||
"SANDBOX": "Sandbox",
|
||||
"LIVE": "Live"
|
||||
}
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"TITLE": "Webhook Configuration",
|
||||
"DESCRIPTION": "Webhook URL is required for your WhatsApp Business Account to receive messages from customers",
|
||||
"ACTION_REQUIRED": "Webhook not configured",
|
||||
"REGISTER_BUTTON": "Register Webhook",
|
||||
"REGISTER_SUCCESS": "Webhook registered successfully",
|
||||
"REGISTER_ERROR": "Failed to register webhook. Please try again.",
|
||||
"CONFIGURED_SUCCESS": "Webhook configured successfully",
|
||||
"URL_MISMATCH": "Webhook URL mismatch"
|
||||
}
|
||||
},
|
||||
"SETTINGS": "Settings",
|
||||
@@ -694,7 +711,7 @@
|
||||
"ALLOWED_DOMAINS": {
|
||||
"TITLE": "Allowed Domains",
|
||||
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
|
||||
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
|
||||
"PLACEHOLDER": "example.com, www.example.com, app.example.com"
|
||||
},
|
||||
"INBOX_AGENTS": "Agents",
|
||||
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
|
||||
@@ -708,8 +725,8 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
|
||||
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
|
||||
@@ -758,6 +775,7 @@
|
||||
"LABEL": "Help Center",
|
||||
"PLACEHOLDER": "Select Help Center",
|
||||
"SELECT_PLACEHOLDER": "Select Help Center",
|
||||
"NONE": "None",
|
||||
"REMOVE": "Remove Help Center",
|
||||
"SUB_TEXT": "Attach a Help Center with the inbox"
|
||||
},
|
||||
@@ -766,6 +784,53 @@
|
||||
"MAX_ASSIGNMENT_LIMIT_RANGE_ERROR": "Please enter a value greater than 0",
|
||||
"MAX_ASSIGNMENT_LIMIT_SUB_TEXT": "Limit the maximum number of conversations from this inbox that can be auto assigned to an agent"
|
||||
},
|
||||
"ASSIGNMENT": {
|
||||
"TITLE": "Conversation Assignment",
|
||||
"DESCRIPTION": "Automatically assign incoming conversations to available agents based on assignment policies",
|
||||
"ENABLE_AUTO_ASSIGNMENT": "Enable automatic conversation assignment",
|
||||
"DEFAULT_RULES_TITLE": "Default assignment rules",
|
||||
"DEFAULT_RULES_DESCRIPTION": "Using the default assignment behavior for all conversations",
|
||||
"DEFAULT_RULE_1": "Earliest created conversations first",
|
||||
"DEFAULT_RULE_2": "Round robin distribution",
|
||||
"CUSTOMIZE_WITH_POLICY": "Customize with assignment policy",
|
||||
"USING_POLICY": "Using custom assignment policy for this inbox",
|
||||
"CUSTOMIZE_POLICY": "Customize with assignment policy",
|
||||
"DELETE_POLICY": "Delete policy",
|
||||
"POLICY_LABEL": "Assignment policy",
|
||||
"ASSIGNMENT_ORDER_LABEL": "Assignment Order",
|
||||
"ASSIGNMENT_METHOD_LABEL": "Assignment Method",
|
||||
"POLICY_STATUS": {
|
||||
"ACTIVE": "Active",
|
||||
"INACTIVE": "Inactive"
|
||||
},
|
||||
"PRIORITY": {
|
||||
"EARLIEST_CREATED": "Earliest created",
|
||||
"LONGEST_WAITING": "Longest waiting"
|
||||
},
|
||||
"METHOD": {
|
||||
"ROUND_ROBIN": "Round robin",
|
||||
"BALANCED": "Balanced assignment"
|
||||
},
|
||||
"UPGRADE_PROMPT": "Custom assignment policies are available on the Business plan",
|
||||
"UPGRADE_TO_BUSINESS": "Upgrade to Business",
|
||||
"DEFAULT_POLICY_LINKED": "Default policy linked",
|
||||
"DEFAULT_POLICY_DESCRIPTION": "Link a custom assignment policy to customize how conversations are assigned to agents in this inbox.",
|
||||
"LINK_EXISTING_POLICY": "Link existing policy",
|
||||
"CREATE_NEW_POLICY": "Create new policy",
|
||||
"NO_POLICIES": "No assignment policies found",
|
||||
"VIEW_ALL_POLICIES": "View all policies",
|
||||
"CURRENT_BEHAVIOR": "Currently using default assignment behavior:",
|
||||
"LINK_SUCCESS": "Assignment policy linked successfully",
|
||||
"LINK_ERROR": "Failed to link assignment policy"
|
||||
},
|
||||
"ASSIGNMENT_POLICY": {
|
||||
"DELETE_CONFIRM_TITLE": "Delete assignment policy?",
|
||||
"DELETE_CONFIRM_MESSAGE": "Are you sure you want to remove this assignment policy from this inbox? The inbox will revert to default assignment rules.",
|
||||
"CANCEL": "Cancel",
|
||||
"CONFIRM_DELETE": "Delete",
|
||||
"DELETE_SUCCESS": "Assignment policy removed successfully",
|
||||
"DELETE_ERROR": "Failed to remove assignment policy"
|
||||
},
|
||||
"FACEBOOK_REAUTHORIZE": {
|
||||
"TITLE": "Reauthorize",
|
||||
"SUBTITLE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
|
||||
@@ -837,6 +902,20 @@
|
||||
"CONFIRM": "Create new template",
|
||||
"CANCEL": "Go back"
|
||||
},
|
||||
"UTILITY_ANALYZER": {
|
||||
"ACTION": "Check utility fit",
|
||||
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
|
||||
"RESULT_LABEL": "Meta category prediction",
|
||||
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
|
||||
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
|
||||
"APPLY": "Use this rewrite",
|
||||
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
|
||||
"CLASSIFICATION": {
|
||||
"LIKELY_UTILITY": "Likely Utility",
|
||||
"LIKELY_MARKETING": "Likely Marketing",
|
||||
"UNCLEAR": "Needs clarification"
|
||||
}
|
||||
},
|
||||
"SURVEY_RULE": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -848,7 +927,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"NOTE": "Note: CSAT surveys are sent only once per conversation",
|
||||
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
|
||||
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
@@ -864,9 +943,11 @@
|
||||
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
|
||||
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours visitors can be warned with a message and a pre-chat form.",
|
||||
"DAY": {
|
||||
"DAY": "Day",
|
||||
"AVAILABILITY": "Availability",
|
||||
"HOURS": "Hours",
|
||||
"ENABLE": "Enable availability for this day",
|
||||
"UNAVAILABLE": "Unavailable",
|
||||
"HOURS": "hours",
|
||||
"VALIDATION_ERROR": "Starting time should be before closing time.",
|
||||
"CHOOSE": "Choose"
|
||||
},
|
||||
@@ -973,11 +1054,12 @@
|
||||
"IN_A_DAY": "In a day"
|
||||
},
|
||||
"WIDGET_COLOR_LABEL": "Widget Color",
|
||||
"WIDGET_BUBBLE_POSITION_LABEL": "Widget Bubble Position",
|
||||
"WIDGET_BUBBLE_TYPE_LABEL": "Widget Bubble Type",
|
||||
"WIDGET_BUBBLE": "Bubble",
|
||||
"WIDGET_BUBBLE_POSITION_LABEL": "Position:",
|
||||
"WIDGET_BUBBLE_TYPE_LABEL": "Type:",
|
||||
"WIDGET_BUBBLE_LAUNCHER_TITLE": {
|
||||
"DEFAULT": "Chat with us",
|
||||
"LABEL": "Widget Bubble Launcher Title",
|
||||
"LABEL": "Launcher Title",
|
||||
"PLACE_HOLDER": "Chat with us"
|
||||
},
|
||||
"UPDATE": {
|
||||
@@ -1002,7 +1084,7 @@
|
||||
},
|
||||
"WIDGET_SCREEN": {
|
||||
"DEFAULT": "Default",
|
||||
"CHAT": "Chat"
|
||||
"CHAT": "Chat mode"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
"FETCHING": "Fetching Integrations",
|
||||
"NO_HOOK_CONFIGURED": "There are no {integrationId} integrations configured in this account.",
|
||||
"HEADER": "Applications",
|
||||
"COUNT": "{n} integration | {n} integrations",
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"NO_RESULTS": "No results found matching your search",
|
||||
"STATUS": {
|
||||
"ENABLED": "Enabled",
|
||||
"DISABLED": "Disabled"
|
||||
@@ -31,6 +34,7 @@
|
||||
"LIST": {
|
||||
"FETCHING": "Fetching integration hooks",
|
||||
"INBOX": "Inbox",
|
||||
"ACTIONS": "Actions",
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"INTEGRATION_SETTINGS": {
|
||||
"SHOPIFY": {
|
||||
"HEADER": "Shopify",
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Shopify Integration",
|
||||
"MESSAGE": "Are you sure you want to delete the Shopify integration?"
|
||||
@@ -19,6 +20,8 @@
|
||||
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
|
||||
"LEARN_MORE": "Learn more about integrations",
|
||||
"LOADING": "Fetching integrations",
|
||||
"SEARCH_PLACEHOLDER": "Search integrations...",
|
||||
"NO_RESULTS": "No integrations found matching your search",
|
||||
"CAPTAIN": {
|
||||
"DISABLED": "Captain is not enabled on your account.",
|
||||
"CLICK_HERE_TO_CONFIGURE": "Click here to configure",
|
||||
@@ -28,6 +31,17 @@
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"SECRET": {
|
||||
"LABEL": "Secret",
|
||||
"COPY": "Copy secret to clipboard",
|
||||
"COPY_SUCCESS": "Secret copied to clipboard",
|
||||
"TOGGLE": "Toggle secret visibility",
|
||||
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
|
||||
"DONE": "Done"
|
||||
},
|
||||
"COUNT": "{n} webhook | {n} webhooks",
|
||||
"SEARCH_PLACEHOLDER": "Search webhooks...",
|
||||
"NO_RESULTS": "No webhooks found matching your search",
|
||||
"FORM": {
|
||||
"CANCEL": "Cancel",
|
||||
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
|
||||
@@ -104,6 +118,7 @@
|
||||
}
|
||||
},
|
||||
"SLACK": {
|
||||
"HEADER": "Slack",
|
||||
"DELETE": "Delete",
|
||||
"DELETE_CONFIRMATION": {
|
||||
"TITLE": "Delete the integration",
|
||||
@@ -145,7 +160,29 @@
|
||||
"EXPAND": "Expand",
|
||||
"MAKE_FRIENDLY": "Change message tone to friendly",
|
||||
"MAKE_FORMAL": "Use formal tone",
|
||||
"SIMPLIFY": "Simplify"
|
||||
"SIMPLIFY": "Simplify",
|
||||
"CONFIDENT": "Use confident tone",
|
||||
"PROFESSIONAL": "Use professional tone",
|
||||
"CASUAL": "Use casual tone",
|
||||
"STRAIGHTFORWARD": "Use straightforward tone"
|
||||
},
|
||||
"REPLY_OPTIONS": {
|
||||
"IMPROVE_REPLY": "Improve reply",
|
||||
"IMPROVE_REPLY_SELECTION": "Improve the selection",
|
||||
"CHANGE_TONE": {
|
||||
"TITLE": "Change tone",
|
||||
"OPTIONS": {
|
||||
"PROFESSIONAL": "Professional",
|
||||
"CASUAL": "Casual",
|
||||
"STRAIGHTFORWARD": "Straightforward",
|
||||
"CONFIDENT": "Confident",
|
||||
"FRIENDLY": "Friendly"
|
||||
}
|
||||
},
|
||||
"GRAMMAR": "Fix grammar & spelling",
|
||||
"SUGGESTION": "Suggest a reply",
|
||||
"SUMMARIZE": "Summarize the conversation",
|
||||
"ASK_COPILOT": "Ask Copilot"
|
||||
},
|
||||
"ASSISTANCE_MODAL": {
|
||||
"DRAFT_TITLE": "Draft content",
|
||||
@@ -201,12 +238,16 @@
|
||||
"SIDEBAR_TXT": "<p><b>Dashboard Apps</b></p><p>Dashboard Apps allow organizations to embed an application inside the Chatwoot dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that inside the dashboard to provide user information, their orders, or their previous payment history.</p><p>When you embed your application using the dashboard in Chatwoot, your application will get the context of the conversation and contact as a window event. Implement a listener for the message event on your page to receive the context.</p><p>To add a new dashboard app, click on the button 'Add a new dashboard app'.</p>",
|
||||
"DESCRIPTION": "Dashboard Apps allow organizations to embed an application inside the dashboard to provide the context for customer support agents. This feature allows you to create an application independently and embed that to provide user information, their orders, or their previous payment history.",
|
||||
"LEARN_MORE": "Learn more about Dashboard Apps",
|
||||
"COUNT": "{n} dashboard app | {n} dashboard apps",
|
||||
"SEARCH_PLACEHOLDER": "Search dashboard apps...",
|
||||
"NO_RESULTS": "No dashboard apps found matching your search",
|
||||
"LIST": {
|
||||
"404": "There are no dashboard apps configured on this account yet",
|
||||
"LOADING": "Fetching dashboard apps...",
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "Name",
|
||||
"ENDPOINT": "Endpoint"
|
||||
"ENDPOINT": "Endpoint",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"EDIT_TOOLTIP": "Edit app",
|
||||
"DELETE_TOOLTIP": "Delete app"
|
||||
@@ -243,6 +284,7 @@
|
||||
}
|
||||
},
|
||||
"LINEAR": {
|
||||
"HEADER": "Linear",
|
||||
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
|
||||
"LOADING": "Fetching linear issues...",
|
||||
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
|
||||
@@ -337,6 +379,7 @@
|
||||
}
|
||||
},
|
||||
"NOTION": {
|
||||
"HEADER": "Notion",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the Notion integration?",
|
||||
"MESSAGE": "Deleting this integration will remove access to your Notion workspace and stop all related functionality.",
|
||||
@@ -406,6 +449,7 @@
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "Fetching labels",
|
||||
"DESCRIPTION": "Labels help you categorize and prioritize conversations and leads. You can assign a label to a conversation or contact using the side panel.",
|
||||
"LEARN_MORE": "Learn more about labels",
|
||||
"COUNT": "{n} label | {n} labels",
|
||||
"SEARCH_PLACEHOLDER": "Search labels...",
|
||||
"NO_RESULTS": "No labels found matching your search",
|
||||
"SEARCH_404": "There are no items matching this query",
|
||||
"LIST": {
|
||||
"404": "There are no labels available in this account.",
|
||||
@@ -13,7 +16,8 @@
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "Name",
|
||||
"DESCRIPTION": "Description",
|
||||
"COLOR": "Color"
|
||||
"COLOR": "Color",
|
||||
"ACTION": "Actions"
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
"HEADER": "Macros",
|
||||
"DESCRIPTION": "A macro is a set of saved actions that help customer service agents easily complete tasks. The agents can define a set of actions like tagging a conversation with a label, sending an email transcript, updating a custom attribute, etc., and they can run these actions in a single click.",
|
||||
"LEARN_MORE": "Learn more about macros",
|
||||
"COUNT": "{n} macro | {n} macros",
|
||||
"HEADER_BTN_TXT": "Add a new macro",
|
||||
"HEADER_BTN_TXT_SAVE": "Save macro",
|
||||
"LOADING": "Fetching macros",
|
||||
"SEARCH_PLACEHOLDER": "Search macros...",
|
||||
"NO_RESULTS": "No macros found matching your search",
|
||||
"ERROR": "Something went wrong. Please try again",
|
||||
"ORDER_INFO": "Macros will run in the order you add your actions. You can rearrange them by dragging them by the handle beside each node.",
|
||||
"ADD": {
|
||||
@@ -29,7 +32,8 @@
|
||||
"NAME": "Name",
|
||||
"CREATED BY": "Created by",
|
||||
"LAST_UPDATED_BY": "Last updated by",
|
||||
"VISIBILITY": "Visibility"
|
||||
"VISIBILITY": "Visibility",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No macros found"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"MFA_SETTINGS": {
|
||||
"TITLE": "Two-Factor Authentication",
|
||||
"SUBTITLE": "Secure your account with TOTP-based authentication",
|
||||
"SUBTITLE": "Protect your account from unauthorized access with TOTP-based authentication. This adds an extra layer of security to your account.",
|
||||
"DESCRIPTION": "Add an extra layer of security to your account using a time-based one-time password (TOTP)",
|
||||
"STATUS_TITLE": "Authentication Status",
|
||||
"STATUS_DESCRIPTION": "Manage your two-factor authentication settings and backup recovery codes",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"HEADER": "Conversations",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
|
||||
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
||||
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
||||
"METRICS": {
|
||||
@@ -128,11 +128,16 @@
|
||||
},
|
||||
"AGENT_REPORTS": {
|
||||
"HEADER": "Agents Overview",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent’s name to learn more.",
|
||||
"DESCRIPTION": "Easily track agent performance with key metrics such as conversations, response times, resolution times, and resolved cases. Click an agent's name to learn more.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Agent",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -201,6 +206,11 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Label",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"LABELS": "Search labels"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -271,6 +281,11 @@
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"FILTERS": {
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"INBOXES": "Search inboxes"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -334,11 +349,19 @@
|
||||
},
|
||||
"TEAM_REPORTS": {
|
||||
"HEADER": "Team Overview",
|
||||
"DESCRIPTION": "Get a snapshot of your team’s performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"DESCRIPTION": "Get a snapshot of your team's performance with essential metrics, including conversations, response times, resolution times, and resolved cases. Click a team name for more details.",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_TEAM_REPORTS": "Download team reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Team",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"TEAMS": "Search teams"
|
||||
}
|
||||
},
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -402,22 +425,48 @@
|
||||
},
|
||||
"CSAT_REPORTS": {
|
||||
"HEADER": "CSAT Reports",
|
||||
"NO_RECORDS": "There are no CSAT survey responses available.",
|
||||
"NO_RECORDS": "No responses yet",
|
||||
"NO_RECORDS_DESCRIPTION": "CSAT survey responses will appear here once customers start providing feedback.",
|
||||
"DOWNLOAD": "Download CSAT Reports",
|
||||
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
|
||||
"FILTERS": {
|
||||
"ADD_FILTER": "Add filter",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"NO_FILTER": "No filters available",
|
||||
"INPUT_PLACEHOLDER": {
|
||||
"AGENTS": "Search agents",
|
||||
"INBOXES": "Search inboxes",
|
||||
"TEAMS": "Search teams",
|
||||
"RATINGS": "Search ratings"
|
||||
},
|
||||
"AGENTS": {
|
||||
"PLACEHOLDER": "Choose Agents"
|
||||
"LABEL": "Agent"
|
||||
},
|
||||
"INBOXES": {
|
||||
"LABEL": "Inbox"
|
||||
},
|
||||
"TEAMS": {
|
||||
"LABEL": "Team"
|
||||
},
|
||||
"RATINGS": {
|
||||
"LABEL": "Rating"
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
"HEADER": {
|
||||
"CONTACT_NAME": "Contact",
|
||||
"AGENT_NAME": "Assigned agent",
|
||||
"AGENT_NAME": "Agent",
|
||||
"RATING": "Rating",
|
||||
"FEEDBACK_TEXT": "Feedback comment"
|
||||
}
|
||||
"FEEDBACK_TEXT": "Feedback comment",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CUSTOMER": "Customer",
|
||||
"RESPONSE": "Response",
|
||||
"HANDLED_BY": "Handled by"
|
||||
},
|
||||
"UNKNOWN_CUSTOMER": "Unknown customer"
|
||||
},
|
||||
"NO_AGENT": "No assigned agent",
|
||||
"NO_FEEDBACK": "No feedback provided",
|
||||
"METRIC": {
|
||||
"TOTAL_RESPONSES": {
|
||||
"LABEL": "Total responses",
|
||||
@@ -430,6 +479,25 @@
|
||||
"RESPONSE_RATE": {
|
||||
"LABEL": "Response rate",
|
||||
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
|
||||
},
|
||||
"RATING_DISTRIBUTION": "Rating distribution"
|
||||
},
|
||||
"REVIEW_NOTES": {
|
||||
"TITLE": "Review notes",
|
||||
"PLACEHOLDER": "Add review notes about this rating...",
|
||||
"SAVE": "Save",
|
||||
"CANCEL": "Cancel",
|
||||
"SAVING": "Saving...",
|
||||
"SAVED": "Notes saved successfully",
|
||||
"SAVE_ERROR": "Failed to save notes",
|
||||
"UPDATED_BY": "Updated by {name} {time}",
|
||||
"UPDATED_BY_LABEL": "Updated by",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to add review notes",
|
||||
"AVAILABLE_ON": "The review notes feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Add internal context to every CSAT response with review notes. Capture what really happened, spot patterns faster, and make better decisions from your feedback.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -273,7 +273,8 @@
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available.",
|
||||
"INSTAGRAM_STORY_REPLY": "Replied to your story:"
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "See on map"
|
||||
@@ -378,7 +379,57 @@
|
||||
"INFO_SHORT": "Automatically mark offline when you aren't using the app."
|
||||
},
|
||||
"DOCS": "Read docs",
|
||||
"SECURITY": "Security"
|
||||
"SECURITY": "Security",
|
||||
"CAPTAIN_AI": "Captain",
|
||||
"CONVERSATION_WORKFLOW": "Conversation Workflow"
|
||||
},
|
||||
"CAPTAIN_SETTINGS": {
|
||||
"TITLE": "Captain Settings",
|
||||
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
|
||||
"LOADING": "Loading Captain configuration...",
|
||||
"LINK_TEXT": "Learn more about Captain Credits",
|
||||
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
|
||||
"MODEL_CONFIG": {
|
||||
"TITLE": "Model Configuration",
|
||||
"DESCRIPTION": "Select AI models for different features.",
|
||||
"SELECT_MODEL": "Select model",
|
||||
"CREDITS_PER_MESSAGE": "{credits} credit/message",
|
||||
"COMING_SOON": "Coming soon",
|
||||
"EDITOR": {
|
||||
"TITLE": "Editor Features",
|
||||
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
|
||||
},
|
||||
"ASSISTANT": {
|
||||
"TITLE": "Assistant",
|
||||
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
|
||||
},
|
||||
"COPILOT": {
|
||||
"TITLE": "Co-pilot",
|
||||
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
|
||||
}
|
||||
},
|
||||
"FEATURES": {
|
||||
"TITLE": "Features",
|
||||
"DESCRIPTION": "Enable or disable AI-powered features.",
|
||||
"AUDIO_TRANSCRIPTION": {
|
||||
"TITLE": "Audio Transcription",
|
||||
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
|
||||
},
|
||||
"HELP_CENTER_SEARCH": {
|
||||
"TITLE": "Help Center Search Indexing",
|
||||
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
|
||||
},
|
||||
"LABEL_SUGGESTION": {
|
||||
"TITLE": "Label Suggestion",
|
||||
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
|
||||
"MODEL_TITLE": "Label Suggestion Model",
|
||||
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS": "Captain settings updated successfully.",
|
||||
"ERROR": "Failed to update Captain settings. Please try again."
|
||||
}
|
||||
},
|
||||
"BILLING_SETTINGS": {
|
||||
"TITLE": "Billing",
|
||||
@@ -506,6 +557,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONVERSATION_WORKFLOW": {
|
||||
"INDEX": {
|
||||
"HEADER": {
|
||||
"TITLE": "Conversation Workflows",
|
||||
"DESCRIPTION": "Configure rules and required fields for conversation resolution."
|
||||
}
|
||||
},
|
||||
"REQUIRED_ATTRIBUTES": {
|
||||
"TITLE": "Attributes required on resolution",
|
||||
"DESCRIPTION": "When resolving a conversation, agents will be prompted to fill these attributes if they haven't yet.",
|
||||
"NO_ATTRIBUTES": "No attributes added yet",
|
||||
"ADD": {
|
||||
"TITLE": "Add Attributes",
|
||||
"SEARCH_PLACEHOLDER": "Search attributes"
|
||||
},
|
||||
"SAVE": {
|
||||
"SUCCESS": "Required attributes updated",
|
||||
"ERROR": "Could not update required attributes, please try again"
|
||||
},
|
||||
"MODAL": {
|
||||
"TITLE": "Resolve conversation",
|
||||
"DESCRIPTION": "Please fill in the following custom attributes before resolving this conversation",
|
||||
"ACTIONS": {
|
||||
"RESOLVE": "Resolve conversation",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"PLACEHOLDERS": {
|
||||
"TEXT": "Write a note...",
|
||||
"NUMBER": "Enter a number",
|
||||
"LINK": "Add a link",
|
||||
"DATE": "Pick a date",
|
||||
"LIST": "Select an option"
|
||||
},
|
||||
"CHECKBOX": {
|
||||
"YES": "Yes",
|
||||
"NO": "No"
|
||||
}
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use required attributes",
|
||||
"AVAILABLE_ON": "The required conversation attributes feature is available on the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to prompt agents to fill required attributes before conversation resolution.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The required conversation attributes feature is available on the paid plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade to a paid plan to enforce required attributes before conversation resolution.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
}
|
||||
}
|
||||
},
|
||||
"CREATE_ACCOUNT": {
|
||||
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",
|
||||
"NEW_ACCOUNT": "New Account",
|
||||
@@ -591,7 +694,8 @@
|
||||
"CREATE_BUTTON": "Create policy",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy"
|
||||
"ERROR_MESSAGE": "Failed to create assignment policy",
|
||||
"INBOX_LINKED": "Inbox has been linked to the policy"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -605,6 +709,12 @@
|
||||
"CONFIRM_BUTTON_LABEL": "Continue",
|
||||
"CANCEL_BUTTON_LABEL": "Cancel"
|
||||
},
|
||||
"INBOX_LINK_PROMPT": {
|
||||
"TITLE": "Link inbox to policy",
|
||||
"DESCRIPTION": "Would you like to link this inbox to the assignment policy?",
|
||||
"LINK_BUTTON": "Link inbox",
|
||||
"CANCEL_BUTTON": "Skip"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Assignment policy updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update assignment policy"
|
||||
@@ -643,7 +753,9 @@
|
||||
},
|
||||
"BALANCED": {
|
||||
"LABEL": "Balanced",
|
||||
"DESCRIPTION": "Assign conversations based on available capacity."
|
||||
"DESCRIPTION": "Assign conversations based on available capacity.",
|
||||
"PREMIUM_MESSAGE": "Upgrade to access balanced assignment and agent capacity management.",
|
||||
"PREMIUM_BADGE": "Premium"
|
||||
}
|
||||
},
|
||||
"ASSIGNMENT_PRIORITY": {
|
||||
@@ -729,6 +841,20 @@
|
||||
"SUCCESS_MESSAGE": "Agent removed from policy successfully",
|
||||
"ERROR_MESSAGE": "Failed to remove agent from policy"
|
||||
}
|
||||
},
|
||||
"INBOX_LIMIT_API": {
|
||||
"ADD": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit added successfully",
|
||||
"ERROR_MESSAGE": "Failed to add inbox limit"
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update inbox limit"
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS_MESSAGE": "Inbox limit deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete inbox limit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"FORM": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"REGISTER": {
|
||||
"TRY_WOOT": "Create an account",
|
||||
"GET_STARTED": "Get started with Chatwoot",
|
||||
"TITLE": "Register",
|
||||
"TESTIMONIAL_HEADER": "All it takes is one step to move forward",
|
||||
"TESTIMONIAL_CONTENT": "You're one step away from engaging your customers, retaining them and finding new ones.",
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
"ADD_ACTION_LONG": "Create a new SLA Policy",
|
||||
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
|
||||
"LEARN_MORE": "Learn more about SLA",
|
||||
"COUNT": "{n} SLA | {n} SLAs",
|
||||
"LOADING": "Fetching SLAs",
|
||||
"SEARCH_PLACEHOLDER": "Search SLA...",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "No SLA found matching your search"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create SLAs",
|
||||
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
|
||||
@@ -20,14 +25,18 @@
|
||||
},
|
||||
"LIST": {
|
||||
"404": "There are no SLAs available in this account.",
|
||||
"TABLE_HEADER": {
|
||||
"SLA": "SLA",
|
||||
"BUSINESS_HOURS": "Business hours"
|
||||
},
|
||||
"EMPTY": {
|
||||
"TITLE_1": "Enterprise P0",
|
||||
"DESC_1": "Issues raised by enterprise customers, that require immediate attention.",
|
||||
"TITLE_2": "Enterprise P1",
|
||||
"DESC_2": "Issues raised by enterprise customers, that needs to be acknowledged quickly."
|
||||
},
|
||||
"BUSINESS_HOURS_ON": "Business hours on",
|
||||
"BUSINESS_HOURS_OFF": "Business hours off",
|
||||
"BUSINESS_HOURS_ON": "Turned on",
|
||||
"BUSINESS_HOURS_OFF": "Turned off",
|
||||
"RESPONSE_TYPES": {
|
||||
"FRT": "First response time threshold",
|
||||
"NRT": "Next response time threshold",
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"SNOOZE_PARSER": {
|
||||
"UNITS": {
|
||||
"MINUTE": "minute",
|
||||
"MINUTES": "minutes",
|
||||
"HOUR": "hour",
|
||||
"HOURS": "hours",
|
||||
"DAY": "day",
|
||||
"DAYS": "days",
|
||||
"WEEK": "week",
|
||||
"WEEKS": "weeks",
|
||||
"MONTH": "month",
|
||||
"MONTHS": "months",
|
||||
"YEAR": "year",
|
||||
"YEARS": "years"
|
||||
},
|
||||
"HALF": "half",
|
||||
"NEXT": "next",
|
||||
"THIS": "this",
|
||||
"AT": "at",
|
||||
"IN": "in",
|
||||
"FROM_NOW": "from now",
|
||||
"NEXT_YEAR": "next year",
|
||||
"MERIDIEM": {
|
||||
"AM": "am",
|
||||
"PM": "pm"
|
||||
},
|
||||
"RELATIVE": {
|
||||
"TOMORROW": "tomorrow",
|
||||
"DAY_AFTER_TOMORROW": "day after tomorrow",
|
||||
"NEXT_WEEK": "next week",
|
||||
"NEXT_MONTH": "next month",
|
||||
"THIS_WEEKEND": "this weekend",
|
||||
"NEXT_WEEKEND": "next weekend"
|
||||
},
|
||||
"TIME_OF_DAY": {
|
||||
"MORNING": "morning",
|
||||
"AFTERNOON": "afternoon",
|
||||
"EVENING": "evening",
|
||||
"NIGHT": "night",
|
||||
"NOON": "noon",
|
||||
"MIDNIGHT": "midnight"
|
||||
},
|
||||
"WORD_NUMBERS": {
|
||||
"ONE": "one",
|
||||
"TWO": "two",
|
||||
"THREE": "three",
|
||||
"FOUR": "four",
|
||||
"FIVE": "five",
|
||||
"SIX": "six",
|
||||
"SEVEN": "seven",
|
||||
"EIGHT": "eight",
|
||||
"NINE": "nine",
|
||||
"TEN": "ten",
|
||||
"TWELVE": "twelve",
|
||||
"FIFTEEN": "fifteen",
|
||||
"TWENTY": "twenty",
|
||||
"THIRTY": "thirty"
|
||||
},
|
||||
"ORDINALS": {
|
||||
"FIRST": "first",
|
||||
"SECOND": "second",
|
||||
"THIRD": "third",
|
||||
"FOURTH": "fourth",
|
||||
"FIFTH": "fifth"
|
||||
},
|
||||
"OF": "of",
|
||||
"AFTER": "after",
|
||||
"WEEK": "week",
|
||||
"DAY": "day"
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "Fetching teams",
|
||||
"DESCRIPTION": "Teams allow you to organize agents into groups based on their responsibilities. An agent can belong to multiple teams. When working collaboratively, you can assign conversations to specific teams.",
|
||||
"LEARN_MORE": "Learn more about teams",
|
||||
"COUNT": "{n} team | {n} teams",
|
||||
"SEARCH_PLACEHOLDER": "Search teams...",
|
||||
"NO_RESULTS": "No teams found matching your search",
|
||||
"LIST": {
|
||||
"404": "There are no teams created on this account.",
|
||||
"EDIT_TEAM": "Edit team",
|
||||
@@ -64,8 +67,8 @@
|
||||
"ERROR_MESSAGE": "Couldn't save the team details. Try again."
|
||||
},
|
||||
"AGENTS": {
|
||||
"AGENT": "AGENT",
|
||||
"EMAIL": "EMAIL",
|
||||
"AGENT": "Agent",
|
||||
"EMAIL": "Email",
|
||||
"BUTTON_TEXT": "Add agents",
|
||||
"ADD_AGENTS": "Adding Agents to your Team...",
|
||||
"SELECT": "select",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"LOADING_EDITOR": "جار جلب المحرر...",
|
||||
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"COUNT": "{n} bot | {n} bots",
|
||||
"SEARCH_PLACEHOLDER": "Search bots...",
|
||||
"NO_RESULTS": "No bots found matching your search",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "النظام",
|
||||
"AVATAR": {
|
||||
@@ -34,7 +37,8 @@
|
||||
"LOADING": "جار جلب الروبوتات...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"URL": "رابط Webhook"
|
||||
"URL": "رابط Webhook",
|
||||
"ACTIONS": "الإجراءات"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ADMINISTRATOR": "المدير",
|
||||
"AGENT": "وكيل الدعم"
|
||||
},
|
||||
"COUNT": "{n} وكيل | {n} وكلاء",
|
||||
"LIST": {
|
||||
"404": "لا يوجد وكلاء دعم مرتبطين بهذا الحساب",
|
||||
"TITLE": "إدارة وكلاء الدعم في فريقك",
|
||||
@@ -96,6 +97,8 @@
|
||||
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "البحث عن وكلاء...",
|
||||
"NO_RESULTS": "No agents found matching your search",
|
||||
"SEARCH": {
|
||||
"NO_RESULTS": "لم يتم العثور على النتائج."
|
||||
},
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"LOADING": "جلب الصفات المخصصة",
|
||||
"DESCRIPTION": "سمة مخصصة تتتبع تفاصيل إضافية حول جهات الاتصال أو المحادثات الخاصة بك - مثل خطة الاشتراك أو تاريخ الشراء الأول. يمكنك إضافة أنواع مختلفة من السمات المخصصة، مثل النص أو القوائم أو الأرقام، لالتقاط المعلومات المحددة التي تحتاجها.",
|
||||
"LEARN_MORE": "تعرف على المزيد حول السمات المخصصة",
|
||||
"COUNT": "{n} attribute | {n} attributes",
|
||||
"SEARCH_PLACEHOLDER": "البحث عن صفات...",
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "المحادثات",
|
||||
"CONTACT": "جهات الاتصال"
|
||||
@@ -63,6 +66,10 @@
|
||||
},
|
||||
"ENABLE_REGEX": {
|
||||
"LABEL": "تمكين التحقق من صحة regex"
|
||||
},
|
||||
"BADGES": {
|
||||
"PRE_CHAT": "Pre-chat",
|
||||
"RESOLUTION": "Resolution"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -23,52 +23,52 @@
|
||||
},
|
||||
"DEFAULT_USER": "النظام",
|
||||
"AUTOMATION_RULE": {
|
||||
"ADD": "{agentName} أنشأ قاعدة أتمتة جديدة (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث قاعدة أتمتة (##{id})",
|
||||
"DELETE": "{agentName} حذف قاعدة أتمتة (##{id})"
|
||||
"ADD": "{agentName} أنشأ قاعدة أتمتة جديدة (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث قاعدة أتمتة (#{id})",
|
||||
"DELETE": "{agentName} حذف قاعدة أتمتة (#{id})"
|
||||
},
|
||||
"ACCOUNT_USER": {
|
||||
"ADD": "{agentName} دعا {invitee} إلى الحساب كـ {role}",
|
||||
"EDIT": {
|
||||
"SELF": "{agentName} غير {attributes} الخاصة به إلى {values}",
|
||||
"OTHER": "{agentName} غير {attributes} لـ {user} إلى {values}",
|
||||
"DELETED": "{agentName} غير {attributes} لـ %{user} إلى {values}"
|
||||
"DELETED": "{agentName} غير {attributes} للمستخدم المحذوف إلى {values}"
|
||||
}
|
||||
},
|
||||
"INBOX": {
|
||||
"ADD": "{agentName} أنشأ صندوق وارد جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث صندوق الوارد (##{id})",
|
||||
"DELETE": "{agentName} حذف صندوق الوارد (##{id})"
|
||||
"ADD": "{agentName} أنشأ صندوق وارد جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث صندوق الوارد (#{id})",
|
||||
"DELETE": "{agentName} حذف صندوق الوارد (#{id})"
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"ADD": "{agentName} أنشأ Webhook جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث Webhook (##{id})",
|
||||
"DELETE": "{agentName} حذف Webhook (##{id})"
|
||||
"ADD": "{agentName} أنشأ Webhook جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث Webhook (#{id})",
|
||||
"DELETE": "{agentName} حذف Webhook (#{id})"
|
||||
},
|
||||
"USER_ACTION": {
|
||||
"SIGN_IN": "{agentName} قام بتسجيل الدخول",
|
||||
"SIGN_OUT": "{agentName} قام بتسجيل الخروج"
|
||||
},
|
||||
"TEAM": {
|
||||
"ADD": "{agentName} أنشأ فريق جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث الفريق (##{id})",
|
||||
"DELETE": "{agentName} حذف الفريق (##{id})"
|
||||
"ADD": "{agentName} أنشأ فريق جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث الفريق (#{id})",
|
||||
"DELETE": "{agentName} حذف الفريق (#{id})"
|
||||
},
|
||||
"MACRO": {
|
||||
"ADD": "{agentName} أنشأ ماكرو جديد (##{id})",
|
||||
"EDIT": "{agentName} قام بتحديث ماكرو (##{id})",
|
||||
"DELETE": "{agentName} حذف ماكرو (##{id})"
|
||||
"ADD": "{agentName} أنشأ ماكرو جديد (#{id})",
|
||||
"EDIT": "{agentName} قام بتحديث ماكرو (#{id})",
|
||||
"DELETE": "{agentName} حذف ماكرو (#{id})"
|
||||
},
|
||||
"INBOX_MEMBER": {
|
||||
"ADD": "{agentName} أضاف {user} إلى صندوق الوارد (##{inbox_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من صندوق الوارد (##{inbox_id})"
|
||||
"ADD": "{agentName} أضاف {user} إلى صندوق الوارد (#{inbox_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من صندوق الوارد (#{inbox_id})"
|
||||
},
|
||||
"TEAM_MEMBER": {
|
||||
"ADD": "{agentName} أضاف {user} إلى الفريق (##{team_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من الفريق (##{team_id})"
|
||||
"ADD": "{agentName} أضاف {user} إلى الفريق (#{team_id})",
|
||||
"REMOVE": "{agentName} أزال {user} من الفريق (#{team_id})"
|
||||
},
|
||||
"ACCOUNT": {
|
||||
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (##{id})"
|
||||
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (#{id})"
|
||||
},
|
||||
"CONVERSATION": {
|
||||
"DELETE": "{agentName} deleted conversation #{id}"
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "الأتمتة",
|
||||
"DESCRIPTION": "ويمكن للأتمتة أن تحل محل وتبسط العمليات القائمة التي تتطلب جهداً يدوياً، مثل إضافة تسميات وتعيين المحادثات إلى أنسب وكيل. ويسمح ذلك للفريق بالتركيز على مواطن قوتهم مع تقليل الوقت الذي يقضيه في المهام الروتينية.",
|
||||
"LEARN_MORE": "تعلم المزيد عن الأتمتة",
|
||||
"HEADER_BTN_TXT": "إضافة قاعدة أتمتة",
|
||||
"COUNT": "{n} automation | {n} automations",
|
||||
"HEADER_BTN_TXT": "Create Automation",
|
||||
"LOADING": "جلب قواعد الأتمتة",
|
||||
"SEARCH_PLACEHOLDER": "Search automation rules...",
|
||||
"NO_RESULTS": "No automation rules found matching your search",
|
||||
"ADD": {
|
||||
"TITLE": "إضافة قاعدة أتمتة",
|
||||
"SUBMIT": "إنشاء",
|
||||
@@ -42,9 +45,9 @@
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
"NAME": "الاسم",
|
||||
"DESCRIPTION": "الوصف",
|
||||
"ACTIVE": "مفعل",
|
||||
"CREATED_ON": "تم إنشاؤها في"
|
||||
"CREATED_ON": "تم إنشاؤها في",
|
||||
"ACTIONS": "الإجراءات"
|
||||
},
|
||||
"404": "لم يتم العثور على قواعد أتمتة"
|
||||
},
|
||||
@@ -150,7 +153,8 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "تغيير الأولوية",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "فتح المحادثة"
|
||||
"OPEN_CONVERSATION": "فتح المحادثة",
|
||||
"PENDING_CONVERSATION": "تحديد المحادثة كمعلقة"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
|
||||
"UPDATE_FAILED": "فشل تحديث المحادثات، الرجاء المحاولة مرة أخرى."
|
||||
},
|
||||
"RESOLVE": {
|
||||
"ALL_MISSING_ATTRIBUTES": "لا يمكن حل المحادثات بسبب عدم وجود السمات المطلوبة",
|
||||
"PARTIAL_SUCCESS": "بعض المحادثات تحتاج إلى سمات مطلوبة قبل الحل وتم تخطيها"
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "إضافة وسم",
|
||||
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
"HEADER": "الردود الجاهزة",
|
||||
"LEARN_MORE": "معرفة المزيد عن الاستجابات المعلبة",
|
||||
"DESCRIPTION": "الردود المسبقة هي قوالب رد مكتوبة مسبقاً تساعدك على الرد بسرعة على محادثة. يمكن للوكلاء كتابة حرف '/' يتبعه الرمز المختصر لإدراج رد مسبق أثناء محادثة. ",
|
||||
"COUNT": "{n} canned response | {n} canned responses",
|
||||
"HEADER_BTN_TXT": "إضافة رد جاهز",
|
||||
"LOADING": "جاري جلب الردود الجاهزة...",
|
||||
"SEARCH_PLACEHOLDER": "Search canned responses...",
|
||||
"NO_RESULTS": "No canned responses found matching your search",
|
||||
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام.",
|
||||
"LIST": {
|
||||
"404": "لا توجد ردود جاهزة متوفرة في هذا الحساب.",
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
},
|
||||
"waiting_since_desc": {
|
||||
"TEXT": "الرد المعلق: الأقصر أولاً"
|
||||
},
|
||||
"priority_desc_created_at_asc": {
|
||||
"TEXT": "Priority: Highest first, Created: Oldest first"
|
||||
}
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
@@ -104,7 +107,7 @@
|
||||
"CONTENT": "Shared contact"
|
||||
},
|
||||
"embed": {
|
||||
"CONTENT": "Embedded content"
|
||||
"CONTENT": "المحتوى المضمن"
|
||||
}
|
||||
},
|
||||
"CHAT_SORT_BY_FILTER": {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"COMPANIES": {
|
||||
"HEADER": "Companies",
|
||||
"HEADER": "الشركات",
|
||||
"SORT_BY": {
|
||||
"LABEL": "ترتيب حسب",
|
||||
"OPTIONS": {
|
||||
"NAME": "الاسم",
|
||||
"DOMAIN": "النطاق",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
"CONTACTS_COUNT": "عدد جهات الاتصال"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
"LABEL": "Order",
|
||||
"LABEL": "ترتيب",
|
||||
"OPTIONS": {
|
||||
"ASCENDING": "Ascending",
|
||||
"ASCENDING": "تصاعدي",
|
||||
"DESCENDING": "Descending"
|
||||
}
|
||||
},
|
||||
"SEARCH_PLACEHOLDER": "Search companies...",
|
||||
"LOADING": "Loading companies...",
|
||||
"UNNAMED": "Unnamed Company",
|
||||
"CONTACTS_COUNT": "{n} contact | {n} contacts",
|
||||
"SEARCH_PLACEHOLDER": "البحث في الشركات...",
|
||||
"LOADING": "جاري تحميل الشركات...",
|
||||
"UNNAMED": "شركة بلا اسم",
|
||||
"CONTACTS_COUNT": "جهة اتصال {n} | {n} جهات الاتصال",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No companies found"
|
||||
"TITLE": "لم يتم العثور على شركات"
|
||||
}
|
||||
},
|
||||
"COMPANIES_LAYOUT": {
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
|
||||
"SHOWING": "عرض {startItem} - {endItem} من {totalItems} شركة | عرض {startItem} – {endItem} من الشركات {totalItems}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"CREATED_AT_LABEL": "تم إنشاؤها",
|
||||
"NEW_MESSAGE": "رسالة جديدة",
|
||||
"CALL": "Call",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||
"CALL_INITIATED": "جار الاتصال بجهة الاتصال…",
|
||||
"CALL_FAILED": "تعذر بدء المكالمة. الرجاء المحاولة مرة أخرى.",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
@@ -457,8 +457,11 @@
|
||||
"INSTAGRAM": {
|
||||
"PLACEHOLDER": "Add Instagram"
|
||||
},
|
||||
"TELEGRAM": {
|
||||
"PLACEHOLDER": "Add Telegram"
|
||||
},
|
||||
"TIKTOK": {
|
||||
"PLACEHOLDER": "Add TikTok"
|
||||
"PLACEHOLDER": "إضافة TikTok"
|
||||
},
|
||||
"LINKEDIN": {
|
||||
"PLACEHOLDER": "Add LinkedIn"
|
||||
@@ -573,7 +576,8 @@
|
||||
"SEARCH_EMPTY_STATE_TITLE": "لا توجد جهات اتصال تطابق بحثك 🔍",
|
||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"LOAD_MORE": "تحميل المزيد"
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "تعيين التسميات",
|
||||
@@ -607,7 +611,7 @@
|
||||
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
|
||||
"CONTACT_SELECTOR": {
|
||||
"LABEL": "إلى:",
|
||||
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
|
||||
"TAG_INPUT_PLACEHOLDER": "Enter at least 2 characters to search by name, email, or phone number",
|
||||
"CONTACT_CREATING": "Creating contact..."
|
||||
},
|
||||
"INBOX_SELECTOR": {
|
||||
@@ -618,9 +622,9 @@
|
||||
"SUBJECT_LABEL": "الموضوع :",
|
||||
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
|
||||
"CC_LABEL": "نسخة من البريد:",
|
||||
"CC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"CC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_LABEL": "نسخة خفية من البريد:",
|
||||
"BCC_PLACEHOLDER": "Search for a contact with their email address",
|
||||
"BCC_PLACEHOLDER": "Enter at least 2 characters to search by email",
|
||||
"BCC_BUTTON": "نسخة خفية من البريد"
|
||||
},
|
||||
"MESSAGE_EDITOR": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user