Compare commits

..
Author SHA1 Message Date
Shivam Mishra 914e775963 feat: add daily report task 2026-05-11 15:59:21 +05:30
1148 changed files with 1661 additions and 26192 deletions
+4 -18
View File
@@ -2,22 +2,8 @@
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+)
# Devise 5 is currently blocked by devise-secure_password/devise_token_auth/devise-two-factor.
# Chatwoot does not enable Timeoutable, so the timeout redirect path is not reachable.
- GHSA-jp94-3292-c3xv
# Rails 7.1 has no patched release for the Active Storage proxy range
# advisories. Chatwoot limits proxy range requests locally.
# Chatwoot defaults to Active Storage redirect-style URLs, and its recommended
# storage setup uses local/cloud storage with optional direct uploads to the
# storage provider rather than Rails proxy mode. Revisit if we enable
# rails_storage_proxy or other app-served Active Storage proxy routes.
- CVE-2026-33658
# Rails 7.1 has no patched release for this Active Storage direct-upload
# advisory. Chatwoot filters internal metadata keys locally.
- CVE-2026-33173
- CVE-2026-33174
# Rails 7.1 has no patched release for these Rails advisories. These are not
# reachable through Chatwoot's current usage patterns and should be removed
# once we upgrade to Rails 7.2.3.1+.
- CVE-2026-33168
- CVE-2026-33169
- CVE-2026-33170
- CVE-2026-33176
- CVE-2026-33195
- CVE-2026-33202
+1 -1
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 20
parallelism: 18
steps:
- checkout
- node/install:
+2 -2
View File
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
##-- background job processing --##
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
gem 'sidekiq', '>= 7.3.1'
# We want cron jobs
gem 'sidekiq-cron', '>= 2.4.0'
gem 'sidekiq-cron', '>= 1.12.0'
# for sidekiq healthcheck
gem 'sidekiq_alive'
+12 -17
View File
@@ -196,9 +196,6 @@ GEM
bigdecimal
rexml
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
@@ -215,7 +212,7 @@ GEM
logger
msgpack
datadog-ruby_core_source (3.4.1)
date (3.5.1)
date (3.4.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -577,7 +574,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.24)
net-imap (0.4.20)
date
net-protocol
net-pop (0.1.2)
@@ -593,14 +590,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.3)
nokogiri (1.19.1)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.3-arm64-darwin)
nokogiri (1.19.1-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-darwin)
nokogiri (1.19.1-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.3-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -905,11 +902,10 @@ GEM
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.4.0)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
sidekiq (>= 6)
sidekiq_alive (2.5.0)
gserver (~> 0.0.1)
sidekiq (>= 5, < 9)
@@ -965,7 +961,7 @@ GEM
time_diff (0.3.0)
activesupport
i18n
timeout (0.6.1)
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
faraday (>= 0.9, < 3.0)
@@ -983,7 +979,6 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode (0.4.4.5)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
@@ -1160,8 +1155,8 @@ DEPENDENCIES
sentry-sidekiq (>= 5.19.0)
shopify_api
shoulda-matchers
sidekiq (~> 7.3, >= 7.3.1)
sidekiq-cron (>= 2.4.0)
sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0)
sidekiq_alive
simplecov (>= 0.21)
simplecov_json_formatter
+1 -1
View File
@@ -1 +1 @@
4.14.0
4.13.0
@@ -28,10 +28,6 @@ class Messages::Messenger::MessageBuilder
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
# The Attachment row is saved before the blob is attached, so the
# after_create_commit broadcast bails on `file.attached?`. Re-fire here
# for audio so the bubble updates without waiting on transcription.
attachment.message&.reload&.send_update_event if attachment.file_type.to_sym == :audio
end
def attachment_params(attachment)
-15
View File
@@ -27,8 +27,6 @@ class NotificationBuilder
return if notification_type == 'conversation_creation' && !user_subscribed_to_notification?
# skip notifications for blocked conversations except for user mentions
return if primary_actor.contact.blocked? && notification_type != 'conversation_mention'
# respect conversation access (inbox/team membership and custom-role permissions)
return unless user_can_access_conversation?
user.notifications.create!(
notification_type: notification_type,
@@ -38,17 +36,4 @@ class NotificationBuilder
secondary_actor: secondary_actor || current_user
)
end
def user_can_access_conversation?
conversation = primary_actor.is_a?(Conversation) ? primary_actor : primary_actor.try(:conversation)
return true if conversation.blank?
account_user = AccountUser.find_by(account_id: account.id, user_id: user.id)
return false if account_user.blank?
ConversationPolicy.new(
{ user: user, account: account, account_user: account_user },
conversation
).show?
end
end
+4 -15
View File
@@ -31,24 +31,13 @@ class V2::Reports::BotMetricsBuilder
end
def bot_resolutions_count
# Exclude conversations that also had a handoff in the same range — handoff wins
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
.distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id)
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
.distinct.count
end
def bot_handoff_conversation_ids_subquery
account.reporting_events
.where(name: :conversation_bot_handoff, created_at: range)
.where.not(conversation_id: nil)
.select(:conversation_id)
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
@@ -1,18 +0,0 @@
class Api::V1::Accounts::Contacts::AttachmentsController < Api::V1::Accounts::Contacts::BaseController
RESULTS_PER_PAGE = 100
def index
conversations = Conversations::PermissionFilterService.new(
Current.account.conversations.where(contact_id: @contact.id),
Current.user,
Current.account
).perform
@attachments = Attachment.where(message_id: Message.where(conversation_id: conversations).select(:id))
.includes({ file_attachment: :blob }, message: [:conversation, :inbox, { sender: { avatar_attachment: :blob } }])
.order(created_at: :desc)
.page(params[:page])
.per(RESULTS_PER_PAGE)
@attachments_count = @attachments.total_count
end
end
@@ -28,7 +28,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def attachments
@attachments_count = @conversation.attachments.count
@attachments = @conversation.attachments
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
.includes(:message)
.order(created_at: :desc)
.page(attachment_params[:page])
.per(ATTACHMENT_RESULTS_PER_PAGE)
@@ -9,9 +9,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
def index
@inboxes = policy_scope(Current.account.inboxes)
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
.order_by_name
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
end
def show; end
@@ -87,7 +85,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def fetch_agent_bot
@agent_bot = AgentBot.accessible_to(Current.account).find(params[:agent_bot]) if params[:agent_bot]
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def create_channel
@@ -41,9 +41,9 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :all)
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
@@ -69,7 +69,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
end
def fetch_notification
@notification = current_user.notifications.where(account_id: Current.account.id).find(params[:id])
@notification = current_user.notifications.find(params[:id])
end
def set_current_page
@@ -18,7 +18,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal = Current.account.portals.build(portal_params.merge(live_chat_widget_params))
@portal.custom_domain = parsed_custom_domain
@portal.save!
process_attached_logo if params[:blob_id].present?
process_attached_logo
end
def update
@@ -78,10 +78,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, :layout, *Portal::ANALYTICS_CONFIG_FORMATS.keys.map(&:to_sym),
{ allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
)
end
@@ -90,7 +87,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
return {} unless permitted_params.key?(:inbox_id)
return { channel_web_widget_id: nil } if permitted_params[:inbox_id].blank?
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
inbox = Inbox.find(permitted_params[:inbox_id])
return {} unless inbox.web_widget?
{ channel_web_widget_id: inbox.channel.id }
@@ -101,8 +98,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def parsed_custom_domain
return @portal.custom_domain if @portal.custom_domain.blank?
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
@@ -111,7 +111,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def custom_attributes_params
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
end
def settings_params
@@ -8,8 +8,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
end
def destroy
notification_subscription = current_user.notification_subscriptions
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -83,10 +83,6 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def set_message
# `conversation.messages.find` would be simpler, but `conversation` is `conversations.last`,
# which means a visitor with more than one open thread could not edit a message in any
# but their most recent one. Scoping across all of the visitor's conversations keeps the
# happy path correct for that future multi-conversation widget flow.
@message = Message.where(conversation_id: conversations.select(:id)).find(permitted_params[:id])
@message = @web_widget.inbox.messages.find(permitted_params[:id])
end
end
@@ -24,10 +24,6 @@ class Public::Api::V1::InboxesController < PublicController
def set_conversation
return if params[:conversation_id].blank?
@conversation = if @contact_inbox.hmac_verified?
@contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
else
@contact_inbox.conversations.find_by!(display_id: params[:conversation_id])
end
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end
@@ -1,11 +1,9 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show, :show_markdown]
before_action :set_article, only: [:show]
layout 'portal'
def index
@@ -23,13 +21,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
@parsed_content = render_article_content(@article.content.to_s)
end
def show_markdown
return head :not_found unless @article&.published?
render plain: @article.content.to_s, content_type: 'text/markdown; charset=utf-8'
end
def tracking_pixel
@@ -71,6 +62,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@parsed_content = render_article_content(@article.content.to_s)
end
def set_category
@@ -7,8 +7,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
around_action :set_locale
after_action :allow_iframe_requests
PORTAL_LAYOUTS = %w[classic documentation].freeze
private
def show_plain_layout
@@ -19,14 +17,6 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def set_portal_layout
@portal_layout = PORTAL_LAYOUTS.include?(@portal&.layout) ? @portal.layout : 'classic'
end
def set_view_variant
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
end
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
end
@@ -52,7 +42,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
article_locale = if article.category.present?
article.category.locale
else
article.locale
article.portal.default_locale
end
@locale = validate_and_get_locale(article_locale)
I18n.with_locale(@locale, &)
@@ -1,18 +1,12 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
before_action :load_category_articles, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def index
respond_to do |format|
format.html { redirect_to public_portal_locale_path(@portal.slug, params[:locale]), status: :moved_permanently }
format.json { @categories = @portal.categories.order(position: :asc) }
end
@categories = @portal.categories.order(position: :asc)
end
def show
@@ -27,9 +21,4 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
Rails.logger.info "Category: not found for slug: #{params[:category_slug]}"
render_404 && return if @category.blank?
end
def load_category_articles
@articles = @category.articles.published.order(:position).includes(:author)
@category_authors = @articles.filter_map(&:author).uniq
end
end
@@ -2,10 +2,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :ensure_custom_domain_request, only: [:show]
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :set_portal_layout
before_action :set_view_variant
before_action :ensure_portal_feature_enabled
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
layout 'portal'
def show
@@ -31,28 +28,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
def load_home_data
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
@visible_categories = @portal.categories
.where(locale: @locale)
.joins(:articles).where(articles: { status: :published })
.order(position: :asc)
.group('categories.id')
@popular_topics = @visible_categories.first(3)
@featured = base_articles.order_by_views.limit(6)
@category_contributors = build_category_contributors(@visible_categories)
end
def build_category_contributors(categories)
category_ids = categories.map(&:id)
return {} if category_ids.empty?
@portal.articles
.published
.where(locale: @locale, category_id: category_ids)
.includes(:author)
.group_by(&:category_id)
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
end
end
@@ -27,7 +27,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
if errors.any?
redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ')
else
redirect_to super_admin_settings_path, flash: success_flash
redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully"
end
end
@@ -58,21 +58,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
)
end
def success_notice
message = "#{@config.titleize} settings updated successfully"
return message unless restart_required_config_saved?
"#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere."
end
def success_flash
restart_required_config_saved? ? { success: success_notice } : { notice: success_notice }
end
def restart_required_config_saved?
params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -25,29 +25,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
resource_class.editable
end
def create
resource = new_resource(resource_params)
authorize_resource(resource)
if resource.save
redirect_to after_resource_created_path(resource), flash: success_flash(resource)
else
render :new, locals: {
page: Administrate::Page::Form.new(dashboard, resource)
}, status: :unprocessable_entity
end
end
def update
if requested_resource.update(resource_params)
redirect_to after_resource_updated_path(requested_resource), flash: success_flash(requested_resource)
else
render :edit, locals: {
page: Administrate::Page::Form.new(dashboard, requested_resource)
}, status: :unprocessable_entity
end
end
# Override `resource_params` if you want to transform the submitted
# data before it's persisted. For example, the following would turn all
# empty values into nil values. It uses other APIs such as `resource_class`
@@ -65,20 +42,6 @@ class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationControl
.transform_values { |value| value == '' ? nil : value }.merge(locked: false)
end
private
def success_flash(resource)
message = translate_with_resource('update.success')
message = translate_with_resource('create.success') if action_name == 'create'
return { notice: message } unless restart_required_config?(resource)
{ success: "#{message.delete_suffix('.')}. Restart Chatwoot web and worker processes to apply this change everywhere." }
end
def restart_required_config?(resource)
resource.name.in?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS)
end
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
end
+3 -8
View File
@@ -1,12 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
swagger_root = Rails.root.join('swagger')
file_path = swagger_root.join(derived_path).cleanpath
return head :not_found unless file_path.to_s.start_with?("#{swagger_root}/") && file_path.file?
render inline: file_path.read
render inline: Rails.root.join('swagger', derived_path).read
else
head :not_found
end
@@ -16,8 +11,8 @@ class SwaggerController < ApplicationController
def derived_path
params[:path] ||= 'index.html'
path = Rack::Utils.clean_path_info(params[:path]).delete_prefix('/')
path << ".#{Rack::Utils.clean_path_info(params[:format]).delete_prefix('/')}" unless path.ends_with?(params[:format].to_s)
path = Rack::Utils.clean_path_info(params[:path])
path << ".#{Rack::Utils.clean_path_info(params[:format])}" unless path.ends_with?(params[:format].to_s)
path
end
end
-1
View File
@@ -17,7 +17,6 @@ module FileTypeHelper
def image_file?(content_type)
[
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/bmp',
-11
View File
@@ -96,15 +96,4 @@ module PortalHelper
colors[username.length % colors.size]
end
def format_authors_label(authors)
return if authors.blank?
names = authors.map(&:available_name)
return names.to_sentence if names.size <= 3
I18n.t('public_portal.sidebar.authors_others',
names: names.first(2).join(', '),
count: authors.size - 2)
end
end
+4 -7
View File
@@ -53,12 +53,13 @@ module ReportHelper
end
def resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved, created_at: range)
scope.reporting_events.where(account_id: account.id, name: :conversation_resolved,
created_at: range)
end
def bot_resolutions
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range)
end
def bot_handoffs
@@ -66,10 +67,6 @@ module ReportHelper
created_at: range).distinct
end
def bot_handoff_conversation_ids_subquery
bot_handoffs
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
@@ -6,22 +6,15 @@ class CaptainDocument extends ApiClient {
super('captain/documents', { accountScoped: true });
}
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
get({ page = 1, searchKey, assistantId } = {}) {
return axios.get(this.url, {
params: {
page,
search_key: searchKey,
searchKey,
assistant_id: assistantId,
filter,
source,
sort,
},
});
}
sync(id) {
return axios.post(`${this.url}/${id}/sync`);
}
}
export default new CaptainDocument();
@@ -28,14 +28,6 @@ class CompanyAPI extends ApiClient {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
listNotes(id) {
return axios.get(`${this.url}/${id}/notes`);
}
listConversations(id) {
return axios.get(`${this.url}/${id}/conversations`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
@@ -56,14 +56,9 @@ const closeMobileSidebar = () => {
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
<div
@@ -110,14 +105,9 @@ const closeMobileSidebar = () => {
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
</Transition>
</div>
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-8">
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
@@ -288,7 +288,7 @@ const handleContactSelect = contactId => {
<div
v-else-if="!hasContacts"
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
@@ -346,7 +346,7 @@ const handleContactSelect = contactId => {
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="!px-0 before:hidden bg-transparent"
class="!px-0 before:hidden"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
defineProps({
conversations: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const contactsById = useMapGetter('contacts/getContactById');
const stateInbox = useMapGetter('inboxes/getInboxById');
const accountLabels = useMapGetter('labels/getLabels');
const accountLabelsValue = computed(() => accountLabels.value);
const conversationContact = conversation => {
const sender = conversation.meta?.sender || {};
const contact = contactsById.value(sender.id);
return contact.id ? contact : sender;
};
const conversationInbox = conversation =>
stateInbox.value(conversation.inboxId) || {
name: '',
channelType: conversation.meta?.channel,
};
</script>
<template>
<div
v-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="conversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in conversations"
:key="conversation.id"
:conversation="conversation"
:contact="conversationContact(conversation)"
:state-inbox="conversationInbox(conversation)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p
v-else
class="py-8 px-4 mx-6 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.HISTORY.EMPTY') }}
</p>
</template>
@@ -1,114 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
notes: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const currentUser = useMapGetter('getCurrentUser');
const hasNotes = computed(() => props.notes.length > 0);
const contactName = contact =>
contact?.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const getWrittenBy = note => {
const isCurrentUser = note?.user?.id === currentUser.value.id;
return isCurrentUser
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: note?.user?.name || 'Bot';
};
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
</script>
<template>
<div v-if="hasNotes" class="flex flex-col px-6">
<div class="flex flex-col divide-y divide-n-strong">
<div
v-for="note in notes"
:key="note.id"
class="flex flex-col gap-2 py-4 group/note"
>
<div class="flex items-center gap-1.5 min-w-0">
<Avatar
:name="contactName(note.contact)"
:src="note.contact?.thumbnail"
:size="16"
rounded-full
hide-offline-status
/>
<div
class="flex items-center justify-between min-w-0 gap-1 w-full text-sm text-n-slate-11"
>
<button
type="button"
class="min-w-0 font-medium truncate text-start text-n-slate-12 hover:text-n-blue-11 p-0"
@click="openContact(note.contact.id)"
>
{{ contactName(note.contact) }}
</button>
<div class="min-w-0 truncate">
<span
class="inline-flex items-center gap-1 text-sm text-n-slate-10"
>
<span class="font-medium text-n-slate-11">
{{ getWrittenBy(note) }}
</span>
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
<span class="font-medium text-n-slate-11">
{{ dynamicTime(note.createdAt) }}
</span>
</span>
</div>
</div>
</div>
<p
v-dompurify-html="formatMessage(note.content || '')"
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
/>
</div>
</div>
</div>
<div
v-else-if="isLoading"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<p
v-else
class="py-8 mx-6 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.NOTES.EMPTY') }}
</p>
</template>
@@ -32,7 +32,7 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-20 px-6">
<header class="sticky top-0 z-10 px-6">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
>
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 z-50"
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -5,7 +5,6 @@ import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PortalSwitcher from 'dashboard/components-next/HelpCenter/PortalSwitcher/PortalSwitcher.vue';
import CreatePortalDialog from 'dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue';
@@ -31,10 +30,6 @@ defineProps({
type: Boolean,
default: true,
},
breadcrumbLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:currentPage']);
@@ -49,15 +44,10 @@ const portals = useMapGetter('portals/allPortals');
const currentPortalSlug = computed(() => route.params.portalSlug);
const activePortal = computed(() =>
portals.value?.find(portal => portal.slug === currentPortalSlug.value)
);
const activePortalName = computed(() => activePortal.value?.name);
const activePortalLogo = computed(
() => activePortal.value?.logo?.file_url || ''
);
const activePortalName = computed(() => {
return portals.value?.find(portal => portal.slug === currentPortalSlug.value)
?.name;
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
@@ -75,45 +65,32 @@ const togglePortalSwitcher = () => {
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
>
<nav v-if="activePortalName" class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
<PortalAvatar
:src="activePortalLogo"
:name="activePortalName"
:size="24"
<span
v-if="activePortalName"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3"
@click="togglePortalSwitcher"
/>
<span class="text-lg font-medium text-n-slate-12">
{{ activePortalName }}
</span>
<div class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
:variant="showPortalSwitcher ? 'faded' : 'ghost'"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="togglePortalSwitcher"
/>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
</div>
<template v-if="breadcrumbLabel">
<div class="w-0.5 h-4 rounded-2xl bg-n-weak shrink-0" />
<span class="pl-1.5 text-lg font-medium text-n-slate-11">
{{ breadcrumbLabel }}
</span>
</template>
</nav>
<PortalSwitcher
v-if="showPortalSwitcher"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showPortalSwitcher = false"
@create-portal="createPortalDialogRef.dialogRef.open()"
/>
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
</div>
<slot name="header-actions" />
</div>
@@ -84,20 +84,28 @@ const findCategoryFromSlug = slug => {
return categories.value?.find(category => category.slug === slug);
};
const assignCategoryFromSlug = slug => {
const categoryFromSlug = findCategoryFromSlug(slug);
if (categoryFromSlug) {
selectedCategoryId.value = categoryFromSlug.id;
return categoryFromSlug;
}
return null;
};
const selectedCategory = computed(() => {
if (isNewArticle.value) {
if (selectedCategoryId.value) {
return (
categories.value?.find(c => c.id === selectedCategoryId.value) || null
);
}
if (categorySlugFromRoute.value) {
const categoryFromSlug = findCategoryFromSlug(
const categoryFromSlug = assignCategoryFromSlug(
categorySlugFromRoute.value
);
if (categoryFromSlug) return categoryFromSlug;
}
return categories.value?.[0] || null;
return selectedCategoryId.value
? categories.value.find(
category => category.id === selectedCategoryId.value
)
: categories.value[0] || null;
}
return categories.value.find(
category => category.id === props.article?.category?.id
@@ -168,9 +168,7 @@ const handlePageChange = page => emit('pageChange', page);
const navigateToNewArticlePage = () => {
const { categorySlug, locale } = route.params;
router.push({
name: props.isCategoryArticles
? 'portals_categories_articles_new'
: 'portals_articles_new',
name: 'portals_articles_new',
params: { categorySlug, locale },
});
};
@@ -256,7 +254,6 @@ watch(
:total-items="articlesCount"
:items-per-page="25"
:header="portalName"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.ARTICLES')"
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
@@ -277,7 +274,6 @@ watch(
:categories="categories"
:allowed-locales="allowedLocales"
:has-selected-category="isCategoryArticles"
@new-article="navigateToNewArticlePage"
/>
</div>
</template>
@@ -112,10 +112,7 @@ const reorderCategories = async reorderedGroup => {
</script>
<template>
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.CATEGORIES')"
>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
:categories="categories"
@@ -25,7 +25,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['localeChange', 'newArticle']);
const emit = defineEmits(['localeChange']);
const route = useRoute();
const router = useRouter();
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
/>
</OnClickOutside>
</div>
<div v-else class="relative flex items-center gap-2">
<div v-else class="relative">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
@@ -196,12 +196,6 @@ const handleBreadcrumbClick = () => {
@close="isEditCategoryDialogOpen = false"
/>
</OnClickOutside>
<Button
:label="t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.NEW_ARTICLE')"
icon="i-lucide-plus"
size="sm"
@click="emit('newArticle')"
/>
</div>
</div>
</template>
@@ -31,10 +31,7 @@ const localeCount = computed(() => props.locales?.length);
</script>
<template>
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="$t('HELP_CENTER.BREADCRUMB.LOCALES')"
>
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
@@ -12,7 +12,8 @@ import { isValidSlug } from 'shared/helpers/Validators';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ColorPicker from 'dashboard/components-next/colorpicker/ColorPicker.vue';
const props = defineProps({
@@ -41,12 +42,31 @@ const state = reactive({
slug: '',
widgetColor: '',
homePageLink: '',
liveChatWidgetInboxId: '',
logoUrl: '',
avatarBlobId: '',
});
const originalState = reactive({ ...state });
const liveChatWidgets = computed(() => {
const inboxes = store.getters['inboxes/getInboxes'];
const widgetOptions = inboxes
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({
value: inbox.id,
label: inbox.name,
}));
return [
{
value: '',
label: t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.NONE_OPTION'),
},
...widgetOptions,
];
});
const rules = {
name: { required, minLength: minLength(2) },
slug: {
@@ -96,6 +116,7 @@ watch(
widgetColor: newVal.color,
homePageLink: newVal.homepage_link,
slug: newVal.slug,
liveChatWidgetInboxId: newVal.inbox?.id || '',
});
if (newVal.logo) {
const {
@@ -127,6 +148,7 @@ const handleUpdatePortal = () => {
header_text: state.headerText,
homepage_link: state.homePageLink,
blob_id: state.avatarBlobId,
inbox_id: state.liveChatWidgetInboxId,
};
emit('updatePortal', portal);
};
@@ -182,11 +204,12 @@ const handleAvatarDelete = () => {
<label class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50">
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.AVATAR.LABEL') }}
</label>
<PortalAvatar
<Avatar
:src="state.logoUrl"
:name="state.name"
:size="72"
allow-upload
icon-name="i-lucide-building-2"
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
@@ -280,6 +303,26 @@ const handleAvatarDelete = () => {
@blur="v$.slug.$touch()"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
<label
class="text-sm font-medium whitespace-nowrap py-2.5 text-n-slate-12"
>
{{ t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.LABEL') }}
</label>
<ComboBox
v-model="state.liveChatWidgetInboxId"
:options="liveChatWidgets"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.PLACEHOLDER')
"
:message="
t('HELP_CENTER.PORTAL_SETTINGS.FORM.LIVE_CHAT_WIDGET.HELP_TEXT')
"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div
class="grid items-start justify-between w-full gap-2 grid-cols-[200px,1fr]"
>
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -62,7 +62,6 @@ const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
const updatedDomainAddress = ref('');
const pendingDomain = ref('');
const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
@@ -112,23 +111,14 @@ const updatePortalConfiguration = customDomain => {
id: props.activePortal?.id,
custom_domain: customDomain,
};
pendingDomain.value = customDomain;
emit('updatePortalConfiguration', portal);
addCustomDomainDialogRef.value.dialogRef.close();
if (customDomain) {
updatedDomainAddress.value = customDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
}
};
// The save above is fire-and-forget, so the DNS instructions dialog must not
// open until the domain is actually persisted — otherwise a failed save still
// advances to the next step. We open it once the stored value reflects the
// submitted domain.
watch(customDomainAddress, savedDomain => {
if (!pendingDomain.value || savedDomain !== pendingDomain.value) return;
pendingDomain.value = '';
updatedDomainAddress.value = savedDomain;
dnsConfigurationDialogRef.value.dialogRef.open();
});
const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
@@ -1,79 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PortalBaseSettings from './PortalBaseSettings.vue';
import ConfirmDeletePortalDialog from './ConfirmDeletePortalDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortal', 'deletePortal']);
const { t } = useI18n();
const confirmDeletePortalDialogRef = ref(null);
const activePortalName = computed(() => props.activePortal?.name || '');
const handleUpdatePortal = portal => {
emit('updatePortal', portal);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', props.activePortal);
confirmDeletePortalDialogRef.value.dialogRef.close();
};
</script>
<template>
<div class="flex flex-col w-full gap-4">
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
)
}}
</span>
</div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{ portalName: activePortalName }
)
"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
/>
</div>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</div>
</template>
@@ -1,244 +0,0 @@
<script setup>
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useBranding } from 'shared/composables/useBranding';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const store = useStore();
const { replaceInstallationName } = useBranding();
// Mirrors Portal::ANALYTICS_CONFIG_FORMATS on the backend.
const ANALYTICS_PROVIDERS = [
{
key: 'gtm_container_id',
i18nKey: 'GTM',
icon: 'i-logos-google-tag-manager',
format: /^GTM-[A-Z0-9]+$/,
},
{
key: 'ga4_measurement_id',
i18nKey: 'GA4',
icon: 'i-logos-google-analytics',
format: /^G-[A-Z0-9]+$/,
},
{
key: 'hotjar_site_id',
i18nKey: 'HOTJAR',
icon: 'i-logos-hotjar-icon',
format: /^\d+$/,
},
{
key: 'plausible_domain',
i18nKey: 'PLAUSIBLE',
icon: 'i-woot-plausible',
format: /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i,
},
{
key: 'amplitude_api_key',
i18nKey: 'AMPLITUDE',
icon: 'i-logos-amplitude-icon',
format: /^[a-z0-9]+$/i,
},
{
key: 'clarity_project_id',
i18nKey: 'CLARITY',
icon: 'i-woot-microsoft-clarity',
format: /^[a-z0-9]+$/i,
},
{
key: 'meta_pixel_id',
i18nKey: 'META_PIXEL',
icon: 'i-logos-meta-icon',
format: /^\d+$/,
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const liveChatWidgets = computed(() => {
const widgetOptions = store.getters['inboxes/getInboxes']
.filter(inbox => inbox.channel_type === 'Channel::WebWidget')
.map(inbox => ({ value: inbox.id, label: inbox.name }));
return [
{
value: '',
label: t(
'HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.NONE_OPTION'
),
},
...widgetOptions,
];
});
const state = reactive({
liveChatWidgetInboxId: '',
...Object.fromEntries(ANALYTICS_PROVIDERS.map(({ key }) => [key, ''])),
});
const originalState = reactive({ ...state });
const resetFromPortal = () => {
state.liveChatWidgetInboxId = props.activePortal?.inbox?.id || '';
ANALYTICS_PROVIDERS.forEach(({ key }) => {
state[key] = portalConfig.value[key] || '';
});
Object.assign(originalState, state);
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const liveChatTitle = computed(() =>
replaceInstallationName(
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.TITLE')
)
);
const trimmedAnalyticsValues = computed(() =>
Object.fromEntries(
ANALYTICS_PROVIDERS.map(({ key }) => [key, state[key].trim()])
)
);
const invalidAnalyticsKeys = computed(() =>
ANALYTICS_PROVIDERS.filter(({ key, format }) => {
const value = trimmedAnalyticsValues.value[key];
return value !== '' && !format.test(value);
}).map(({ key }) => key)
);
const isInvalid = key => invalidAnalyticsKeys.value.includes(key);
const hasChanges = computed(
() =>
state.liveChatWidgetInboxId !== originalState.liveChatWidgetInboxId ||
ANALYTICS_PROVIDERS.some(
({ key }) => trimmedAnalyticsValues.value[key] !== originalState[key]
)
);
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
inbox_id: state.liveChatWidgetInboxId,
config: { ...trimmedAnalyticsValues.value },
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.DESCRIPTION') }}
</span>
</div>
<div
class="flex flex-col gap-4 p-4 rounded-xl outline outline-1 outline-n-weak"
>
<div class="flex items-start gap-3">
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-n-alpha-2 text-n-slate-12"
>
<Icon icon="i-lucide-messages-square" class="size-5" />
</div>
<div class="flex flex-col gap-0.5">
<h6 class="text-sm font-medium text-n-slate-12">
{{ liveChatTitle }}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.DESCRIPTION'
)
}}
</span>
</div>
</div>
<ComboBox
v-model="state.liveChatWidgetInboxId"
:options="liveChatWidgets"
:placeholder="
t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.LIVE_CHAT.PLACEHOLDER')
"
class="[&>div>button:not(.focused)]:!outline-n-weak"
/>
</div>
<div
v-for="provider in ANALYTICS_PROVIDERS"
:key="provider.key"
class="flex flex-col gap-4 p-4 rounded-xl outline outline-1 outline-n-weak"
>
<div class="flex items-start gap-3">
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-n-alpha-2 text-n-slate-12"
>
<Icon :icon="provider.icon" class="size-5" />
</div>
<div class="flex flex-col gap-0.5">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.TITLE`
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.DESCRIPTION`
)
}}
</span>
</div>
</div>
<Input
v-model="state[provider.key]"
:placeholder="
t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.PLACEHOLDER`
)
"
:message="
isInvalid(provider.key)
? t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.INVALID`
)
: t(
`HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.${provider.i18nKey}.HELP`
)
"
:message-type="isInvalid(provider.key) ? 'error' : 'info'"
/>
</div>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.INTEGRATIONS.SAVE')"
:disabled="!hasChanges || invalidAnalyticsKeys.length > 0 || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -1,290 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import classicLayoutPreview from './classic-layout-preview.svg?raw';
import documentationLayoutPreview from './documentation-layout-preview.svg?raw';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
activePortal: { type: Object, required: true },
isFetching: { type: Boolean, default: false },
});
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const PORTAL_LAYOUTS = {
CLASSIC: 'classic',
DOCUMENTATION: 'documentation',
};
// `prefix` is the link the help center auto-fills; the DB only stores the handle.
const SOCIAL_PLATFORMS = [
{
key: 'facebook',
label: 'Facebook',
icon: 'i-ri-facebook-circle-fill',
prefix: 'facebook.com/',
},
{ key: 'x', label: 'X', icon: 'i-ri-twitter-x-fill', prefix: 'x.com/' },
{
key: 'instagram',
label: 'Instagram',
icon: 'i-ri-instagram-fill',
prefix: 'instagram.com/',
},
{
key: 'linkedin',
label: 'LinkedIn',
icon: 'i-ri-linkedin-box-fill',
prefix: 'linkedin.com/',
},
{
key: 'youtube',
label: 'YouTube',
icon: 'i-ri-youtube-fill',
prefix: 'youtube.com/',
},
{
key: 'tiktok',
label: 'TikTok',
icon: 'i-ri-tiktok-fill',
prefix: 'tiktok.com/',
},
{
key: 'github',
label: 'GitHub',
icon: 'i-ri-github-fill',
prefix: 'github.com/',
},
{
key: 'whatsapp',
label: 'WhatsApp',
icon: 'i-ri-whatsapp-fill',
prefix: 'wa.me/',
},
];
const portalConfig = computed(() => props.activePortal?.config || {});
const state = reactive({
layout: PORTAL_LAYOUTS.CLASSIC,
socialProfiles: {},
});
const visiblePlatforms = ref([]);
const showAddMenu = ref(false);
let originalSnapshot = '';
const platformByKey = key => SOCIAL_PLATFORMS.find(p => p.key === key);
const trimmedHandle = key => (state.socialProfiles[key] || '').trim();
const buildSocialProfiles = () =>
visiblePlatforms.value.reduce((acc, key) => {
const handle = trimmedHandle(key);
if (handle) acc[key] = handle;
return acc;
}, {});
const snapshot = () =>
JSON.stringify({ layout: state.layout, social: buildSocialProfiles() });
const resetFromPortal = () => {
const savedProfiles = portalConfig.value.social_profiles || {};
state.layout = portalConfig.value.layout || PORTAL_LAYOUTS.CLASSIC;
state.socialProfiles = SOCIAL_PLATFORMS.reduce((acc, { key }) => {
acc[key] = savedProfiles[key] || '';
return acc;
}, {});
visiblePlatforms.value = SOCIAL_PLATFORMS.map(p => p.key).filter(key =>
(savedProfiles[key] || '').trim()
);
originalSnapshot = snapshot();
};
watch(() => props.activePortal, resetFromPortal, {
immediate: true,
deep: true,
});
const hasChanges = computed(() => snapshot() !== originalSnapshot);
const visiblePlatformDetails = computed(() =>
visiblePlatforms.value.map(platformByKey)
);
const addablePlatforms = computed(() =>
SOCIAL_PLATFORMS.filter(p => !visiblePlatforms.value.includes(p.key)).map(
p => ({ label: p.label, value: p.key, action: p.key, icon: p.icon })
)
);
const addPlatform = ({ value }) => {
if (!visiblePlatforms.value.includes(value)) {
visiblePlatforms.value.push(value);
}
showAddMenu.value = false;
};
const removePlatform = key => {
visiblePlatforms.value = visiblePlatforms.value.filter(k => k !== key);
state.socialProfiles[key] = '';
};
const handleSave = () => {
emit('updatePortalConfiguration', {
id: props.activePortal.id,
slug: props.activePortal.slug,
config: {
layout: state.layout,
social_profiles: buildSocialProfiles(),
},
});
};
</script>
<template>
<div class="flex flex-col w-full gap-6">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.HEADER') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.DESCRIPTION') }}
</span>
</div>
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-n-slate-11">
<RadioCard
:id="PORTAL_LAYOUTS.CLASSIC"
:is-active="state.layout === PORTAL_LAYOUTS.CLASSIC"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.CLASSIC.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="classicLayoutPreview" />
</div>
</RadioCard>
<RadioCard
:id="PORTAL_LAYOUTS.DOCUMENTATION"
beta
:is-active="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.TITLE')
"
:description="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.LAYOUT.SIDEBAR.DESCRIPTION'
)
"
@select="value => (state.layout = value)"
>
<div
class="w-full mt-2 rounded-md overflow-hidden border border-solid border-n-weak bg-n-slate-2 dark:bg-n-slate-1"
>
<span v-dompurify-html="documentationLayoutPreview" />
</div>
</RadioCard>
</div>
</section>
<section
v-if="state.layout === PORTAL_LAYOUTS.DOCUMENTATION"
class="flex flex-col gap-3"
>
<div class="flex flex-col gap-1">
<h6 class="text-sm font-medium text-n-slate-12">
{{
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.HEADER')
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.DESCRIPTION'
)
}}
</span>
</div>
<div
v-for="platform in visiblePlatformDetails"
:key="platform.key"
class="flex items-center h-10 gap-1.5 px-3 rounded-lg outline outline-1 outline-n-weak focus-within:outline-n-brand"
>
<Icon :icon="platform.icon" class="size-4 shrink-0 text-n-slate-11" />
<span class="text-sm shrink-0 text-n-slate-10">{{
platform.prefix
}}</span>
<input
v-model="state.socialProfiles[platform.key]"
type="text"
class="flex-1 min-w-0 text-sm bg-transparent outline-none reset-base text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.PLACEHOLDER'
)
"
/>
<Button
icon="i-lucide-x"
color="slate"
variant="ghost"
size="xs"
:aria-label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.REMOVE')
"
@click="removePlatform(platform.key)"
/>
</div>
<div
v-if="addablePlatforms.length"
v-on-clickaway="() => (showAddMenu = false)"
class="relative"
>
<Button
:label="
t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SOCIAL_LINKS.ADD')
"
icon="i-lucide-plus"
color="slate"
variant="faded"
size="sm"
@click="showAddMenu = !showAddMenu"
/>
<DropdownMenu
v-if="showAddMenu"
:menu-items="addablePlatforms"
class="mt-1 w-52 top-full ltr:left-0 rtl:right-0"
@action="addPlatform"
/>
</div>
</section>
<div class="flex justify-end">
<Button
:label="t('HELP_CENTER.PORTAL_SETTINGS.LAYOUT_CONTENT.SAVE')"
:disabled="!hasChanges || isFetching"
@click="handleSave"
/>
</div>
</div>
</template>
@@ -5,12 +5,11 @@ import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import PortalGeneralSettings from './PortalGeneralSettings.vue';
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
import PortalIntegrationsSettings from './PortalIntegrationsSettings.vue';
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
portals: {
@@ -34,30 +33,7 @@ const emit = defineEmits([
const { t } = useI18n();
const route = useRoute();
const SETTINGS_TABS = [
{
id: 'general',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.GENERAL',
icon: 'i-lucide-settings-2',
},
{
id: 'domain',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.DOMAIN',
icon: 'i-lucide-globe',
},
{
id: 'appearance',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.APPEARANCE',
icon: 'i-lucide-palette',
},
{
id: 'integrations',
labelKey: 'HELP_CENTER.PORTAL_SETTINGS.NAV.INTEGRATIONS',
icon: 'i-lucide-blocks',
},
];
const activeTab = ref('general');
const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
@@ -68,6 +44,8 @@ const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
});
const activePortalName = computed(() => activePortal.value?.name || '');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const handleUpdatePortal = portal => {
@@ -86,16 +64,18 @@ const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const handleDeletePortal = portal => {
emit('deletePortal', portal);
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
const handleDeletePortal = () => {
emit('deletePortal', activePortal.value);
confirmDeletePortalDialogRef.value.dialogRef.close();
};
</script>
<template>
<HelpCenterLayout
:show-pagination-footer="false"
:breadcrumb-label="t('HELP_CENTER.BREADCRUMB.SETTINGS')"
>
<HelpCenterLayout :show-pagination-footer="false">
<template #content>
<div
v-if="isLoading"
@@ -103,60 +83,62 @@ const handleDeletePortal = portal => {
>
<Spinner />
</div>
<div v-else-if="activePortal" class="flex items-start w-full gap-8">
<nav class="sticky top-0 flex flex-col w-48 gap-0.5 shrink-0 py-1">
<button
v-for="tab in SETTINGS_TABS"
:key="tab.id"
type="button"
class="flex items-center w-full h-9 gap-2 px-2.5 text-sm transition-colors rounded-lg"
:class="
activeTab === tab.id
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
: 'text-n-slate-11 hover:bg-n-alpha-1 hover:text-n-slate-12'
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
/>
<div class="w-full h-px bg-n-weak" />
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
<div class="flex flex-col gap-2">
<h6 class="text-base font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.HEADER'
)
}}
</h6>
<span class="text-sm text-n-slate-11">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.DESCRIPTION'
)
}}
</span>
</div>
<Button
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.DELETE_PORTAL.BUTTON',
{
portalName: activePortalName,
}
)
"
:aria-current="activeTab === tab.id ? 'page' : undefined"
@click="activeTab = tab.id"
>
<Icon :icon="tab.icon" class="shrink-0 size-4" />
{{ t(tab.labelKey) }}
</button>
</nav>
<div class="flex flex-col flex-1 min-w-0 max-w-[40rem] pb-8">
<PortalGeneralSettings
v-if="activeTab === 'general'"
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
@delete-portal="handleDeletePortal"
/>
<PortalConfigurationSettings
v-else-if="activeTab === 'domain'"
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<PortalLayoutContentSettings
v-else-if="activeTab === 'appearance'"
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
/>
<PortalIntegrationsSettings
v-else-if="activeTab === 'integrations'"
:active-portal="activePortal"
:is-fetching="isFetching"
@update-portal-configuration="handleUpdatePortalConfiguration"
color="ruby"
class="max-w-56 !w-fit"
@click="openConfirmDeletePortalDialog"
/>
</div>
</div>
</template>
<ConfirmDeletePortalDialog
ref="confirmDeletePortalDialogRef"
:active-portal-name="activePortalName"
@delete-portal="handleDeletePortal"
/>
</HelpCenterLayout>
</template>
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="14" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="14" y="22" width="64" height="5" rx="1" fill="currentColor" opacity="0.32"/>
<rect x="14" y="32" width="128" height="9" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="14" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="70" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="14" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="104" y="93" width="82" height="19" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 818 B

@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120" class="w-full h-auto block" aria-hidden="true">
<rect x="0" y="0" width="200" height="14" fill="currentColor" opacity="0.1"/>
<rect x="6" y="5" width="20" height="4" rx="1" fill="currentColor" opacity="0.3"/>
<rect x="0" y="14" width="50" height="106" fill="currentColor" opacity="0.07"/>
<rect x="6" y="22" width="38" height="4" rx="1" fill="currentColor" opacity="0.25"/>
<rect x="6" y="32" width="30" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="40" width="35" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="48" width="28" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="6" y="56" width="32" height="3" rx="1" fill="currentColor" opacity="0.18"/>
<rect x="60" y="25" width="80" height="6" rx="1" fill="currentColor" opacity="0.35"/>
<rect x="60" y="38" width="120" height="8" rx="2" fill="currentColor" opacity="0.18"/>
<rect x="60" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="55" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="60" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="101" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
<rect x="142" y="82" width="37" height="22" rx="2" fill="currentColor" opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,27 +0,0 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
// Renders a portal's logo consistently across the help center — the layout
// header, the portal switcher, and portal settings — so the icon fallback
// and shape stay in one place instead of being copied per call site.
defineProps({
src: { type: String, default: '' },
name: { type: String, default: '' },
size: { type: Number, default: 24 },
allowUpload: { type: Boolean, default: false },
});
const emit = defineEmits(['upload', 'delete']);
</script>
<template>
<Avatar
:src="src"
:name="name"
:size="size"
:allow-upload="allowUpload"
icon-name="i-lucide-building-2"
@upload="emit('upload', $event)"
@delete="emit('delete')"
/>
</template>
@@ -6,7 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { buildPortalURL } from 'dashboard/helper/portalHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import PortalAvatar from 'dashboard/components-next/HelpCenter/PortalAvatar.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createPortal']);
@@ -19,7 +19,6 @@ const DEFAULT_ROUTE = 'portals_articles_index';
const CATEGORY_ROUTE = 'portals_categories_index';
const CATEGORY_SUB_ROUTES = [
'portals_categories_articles_index',
'portals_categories_articles_new',
'portals_categories_articles_edit',
];
@@ -151,11 +150,13 @@ const redirectToPortalHomePage = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ portal.name || '' }}
</span>
<PortalAvatar
<Avatar
v-if="portal"
:name="portal.name"
:src="getPortalThumbnailSrc(portal)"
:size="20"
icon-name="i-lucide-building-2"
rounded-full
/>
</Button>
</div>
@@ -1,113 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedIds: { type: Set, default: () => new Set() },
documents: { type: Array, default: () => [] },
});
const emit = defineEmits([
'update:selectedIds',
'bulkSyncQueued',
'bulkDeleteSucceeded',
]);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialog = ref(null);
const isSyncableDocument = doc =>
!doc.pdf_document && doc.status === 'available' && !doc.sync_in_progress;
const syncableSelectedIds = computed(() => {
if (!props.selectedIds.size) return [];
return props.documents
.filter(doc => props.selectedIds.has(doc.id) && isSyncableDocument(doc))
.map(doc => doc.id);
});
const hasSyncableSelection = computed(
() => syncableSelectedIds.value.length > 0
);
const selectAllLabel = computed(() => {
const count = props.documents.length;
const isAllSelected = props.selectedIds.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.SELECTED', { count: props.selectedIds.size })
);
const handleBulkSync = async () => {
const ids = syncableSelectedIds.value;
if (!ids.length) return;
try {
const response = await store.dispatch('captainBulkActions/handleBulkSync', {
ids,
});
const queuedCount = response?.count ?? response?.ids?.length ?? 0;
let message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.ZERO_MESSAGE');
if (queuedCount === 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE_ONE');
} else if (queuedCount > 1) {
message = t('CAPTAIN.DOCUMENTS.BULK_SYNC.SUCCESS_MESSAGE', {
count: queuedCount,
});
}
useAlert(message);
emit('update:selectedIds', new Set());
if (queuedCount > 0) emit('bulkSyncQueued');
} catch (error) {
useAlert(t('CAPTAIN.DOCUMENTS.BULK_SYNC.ERROR_MESSAGE'));
}
};
</script>
<template>
<div>
<BulkSelectBar
:model-value="selectedIds"
:all-items="documents"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': selectedIds.size > 0 }"
@update:model-value="emit('update:selectedIds', $event)"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template v-if="hasSyncableSelection" #secondaryActions>
<Button
:label="$t('CAPTAIN.DOCUMENTS.BULK_SYNC_BUTTON')"
sm
slate
ghost
icon="i-lucide-refresh-cw"
class="!px-1.5"
@click="handleBulkSync"
/>
</template>
</BulkSelectBar>
<BulkDeleteDialog
ref="bulkDeleteDialog"
:bulk-ids="selectedIds"
type="AssistantDocument"
@delete-success="emit('bulkDeleteSucceeded')"
/>
</div>
</template>
@@ -5,17 +5,14 @@ import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import {
isSafeHttpLink,
isPdfDocument,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import DocumentSyncStatus from 'dashboard/components-next/captain/assistant/DocumentSyncStatus.vue';
const props = defineProps({
id: {
@@ -34,38 +31,10 @@ const props = defineProps({
type: String,
required: true,
},
pdfDocument: {
type: Boolean,
default: false,
},
createdAt: {
type: Number,
required: true,
},
status: {
type: String,
default: null,
},
syncStatus: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
lastSyncErrorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
syncStaleAfterHours: {
type: Number,
default: null,
},
isSelected: {
type: Boolean,
default: false,
@@ -95,20 +64,6 @@ const modelValue = computed({
set: () => emit('select', props.id),
});
const isPdf = computed(() => props.pdfDocument);
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
const canManage = computed(() => checkPermissions(['administrator']));
const isAvailable = computed(() => props.status === 'available');
const canSync = computed(
() => canManage.value && !isPdf.value && isAvailable.value
);
const isSyncing = computed(() => props.syncStatus === 'syncing');
const isFailed = computed(() => props.syncStatus === 'failed');
const isRetryableSync = computed(
() => isFailed.value || (isSyncing.value && !props.syncInProgress)
);
const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
@@ -119,19 +74,7 @@ const menuItems = computed(() => {
},
];
if (canSync.value) {
allOptions.push({
label: isRetryableSync.value
? t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')
: t('CAPTAIN.DOCUMENTS.OPTIONS.SYNC_NOW'),
value: 'sync',
action: 'sync',
icon: 'i-lucide-refresh-cw',
disabled: props.syncInProgress,
});
}
if (canManage.value) {
if (checkPermissions(['administrator'])) {
allOptions.push({
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
value: 'delete',
@@ -143,25 +86,17 @@ const menuItems = computed(() => {
return allOptions;
});
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const createdAt = computed(() => dynamicTime(props.createdAt));
const displayLink = computed(() =>
isPdf.value
? formatDocumentLink(props.externalLink)
: getDocumentDisplayPath(props.externalLink)
);
const displayLink = computed(() => formatDocumentLink(props.externalLink));
const linkIcon = computed(() =>
isPdf.value ? 'i-ph-file-pdf' : 'i-ph-link-simple'
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
</script>
<template>
@@ -206,41 +141,17 @@ const handleRetry = () => {
<span
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
>
<Icon icon="i-woot-captain" />
<i class="i-woot-captain" />
{{ assistant?.name || '' }}
</span>
<a
v-if="!isPdf && hasSafeLink"
:href="externalLink"
:title="externalLink"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11 hover:text-n-slate-12 hover:underline"
@click.stop
>
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
<Icon icon="i-lucide-external-link size-3 shrink-0 opacity-70" />
</a>
<span
v-else
class="flex flex-1 gap-1 justify-start items-center text-sm truncate text-n-slate-11"
>
<Icon :icon="linkIcon" class="shrink-0" />
<i :class="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
:last-synced-at="lastSyncedAt"
:error-code="lastSyncErrorCode"
:sync-in-progress="syncInProgress"
:stale-after-hours="syncStaleAfterHours"
:show-retry="canSync && isRetryableSync"
@retry="handleRetry"
/>
<div v-else class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAtLabel }}
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
{{ createdAt }}
</div>
</div>
</CardLayout>
@@ -1,61 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import DocumentFiltersBar from 'dashboard/components-next/captain/assistant/DocumentFiltersBar.vue';
const emit = defineEmits(['change']);
const source = ref('all');
const status = ref(null);
const sort = ref('recently_updated');
const hasActiveFilters = computed(
() => source.value !== 'all' || Boolean(status.value)
);
const buildParams = (page = 1) => {
const params = { page };
if (source.value !== 'all') params.source = source.value;
if (status.value) params.filter = status.value;
if (sort.value) params.sort = sort.value;
return params;
};
const emitChange = () => emit('change');
const handleSourceSelect = sourceKey => {
source.value = sourceKey;
if (sourceKey !== 'web') status.value = null;
emitChange();
};
const handleStatusSelect = statusKey => {
status.value = statusKey;
if (statusKey) source.value = 'web';
emitChange();
};
const handleSortSelect = sortKey => {
sort.value = sortKey;
emitChange();
};
const reset = () => {
source.value = 'all';
status.value = null;
sort.value = 'recently_updated';
};
defineExpose({ buildParams, reset, hasActiveFilters });
</script>
<template>
<DocumentFiltersBar
:active-source-filter="source"
:active-status-filter="status"
:active-sort="sort"
@select-source="handleSourceSelect"
@select-status="handleStatusSelect"
@select-sort="handleSortSelect"
/>
</template>
@@ -1,141 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
activeSourceFilter: { type: String, default: 'all' },
activeStatusFilter: { type: String, default: null },
activeSort: { type: String, default: 'recently_updated' },
});
const emit = defineEmits(['selectSource', 'selectStatus', 'selectSort']);
const { t } = useI18n();
const openMenu = ref(null);
const MENU_CONFIG = [
{
key: 'source',
activeKey: 'activeSourceFilter',
dropdownClass: 'min-w-48',
options: [
{ labelKey: 'SOURCE.ALL', value: 'all', icon: 'i-lucide-files' },
{ labelKey: 'SOURCE.WEB', value: 'web', icon: 'i-lucide-link' },
{ labelKey: 'SOURCE.PDF', value: 'pdf', icon: 'i-lucide-file-text' },
],
},
{
key: 'status',
activeKey: 'activeStatusFilter',
dropdownClass: 'min-w-52',
options: [
{ labelKey: 'STATUS.ANY', value: null, icon: 'i-lucide-circle-dashed' },
{
labelKey: 'STATUS.UPDATED',
value: 'synced',
icon: 'i-lucide-check-circle',
},
{
labelKey: 'STATUS.NEEDS_UPDATE',
value: 'stale',
icon: 'i-lucide-clock',
},
{
labelKey: 'STATUS.UPDATING',
value: 'syncing',
icon: 'i-lucide-refresh-cw',
},
{
labelKey: 'STATUS.FAILED',
value: 'failed',
icon: 'i-lucide-circle-x',
},
],
},
{
key: 'sort',
activeKey: 'activeSort',
dropdownClass: 'min-w-56',
options: [
{
labelKey: 'SORT.RECENTLY_UPDATED',
value: 'recently_updated',
icon: 'i-lucide-arrow-down-up',
},
{
labelKey: 'SORT.RECENTLY_CREATED',
value: 'recently_created',
icon: 'i-lucide-clock',
},
],
},
];
const filterMenus = computed(() =>
MENU_CONFIG.filter(
menu => !(menu.key === 'status' && props.activeSourceFilter === 'pdf')
).map(menu => {
const active = props[menu.activeKey];
const items = menu.options.map(opt => ({
label: t(`CAPTAIN.DOCUMENTS.FILTERS.${opt.labelKey}`),
value: opt.value,
icon: opt.icon,
action: menu.key,
isSelected: opt.value === active,
}));
return {
...menu,
items,
selected: items.find(item => item.isSelected) || items[0],
};
})
);
const closeMenu = () => {
openMenu.value = null;
};
const toggleMenu = menu => {
openMenu.value = openMenu.value === menu ? null : menu;
};
const handleMenuAction = ({ action, value }) => {
closeMenu();
if (action === 'source') emit('selectSource', value);
else if (action === 'status') emit('selectStatus', value);
else if (action === 'sort') emit('selectSort', value);
};
</script>
<template>
<div
v-on-click-outside="closeMenu"
class="inline-flex flex-wrap items-center gap-2 pt-2 w-fit"
>
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
<Button
:icon="menu.selected.icon"
slate
size="sm"
:class="{ 'bg-n-slate-9/10': openMenu === menu.key }"
@click="toggleMenu(menu.key)"
>
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
</Button>
<DropdownMenu
v-if="openMenu === menu.key"
:menu-items="menu.items"
:class="menu.dropdownClass"
class="top-full mt-2 ltr:left-0 rtl:right-0"
@action="handleMenuAction"
/>
</div>
</div>
</template>
@@ -1,153 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
status: {
type: String,
default: null,
},
lastSyncedAt: {
type: Number,
default: null,
},
errorCode: {
type: String,
default: null,
},
syncInProgress: {
type: Boolean,
default: false,
},
staleAfterHours: {
type: Number,
default: null,
},
showRetry: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['retry']);
const { t } = useI18n();
const SECONDS_PER_HOUR = 3600;
const SYNCING = 'syncing';
const FAILED = 'failed';
const ERROR_CODE_LABELS = {
not_found: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.NOT_FOUND',
access_denied: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.ACCESS_DENIED',
timeout: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.TIMEOUT',
content_empty: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.CONTENT_EMPTY',
fetch_failed: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.FETCH_FAILED',
sync_error: 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.SYNC_ERROR',
};
const DEFAULT_ERROR_LABEL = 'CAPTAIN.DOCUMENTS.SYNC_ERRORS.DEFAULT';
const hasSyncingStatus = computed(() => props.status === SYNCING);
const isSyncing = computed(
() => hasSyncingStatus.value && props.syncInProgress
);
const isStaleSync = computed(
() => hasSyncingStatus.value && !props.syncInProgress
);
const isFailed = computed(() => props.status === FAILED);
const canRetry = computed(() => isFailed.value || isStaleSync.value);
const hasBeenSynced = computed(() => Boolean(props.lastSyncedAt));
const ageInHours = computed(() => {
if (!props.lastSyncedAt) return null;
const nowSeconds = Date.now() / 1000;
return (nowSeconds - props.lastSyncedAt) / SECONDS_PER_HOUR;
});
const staleAfterHours = computed(() => Number(props.staleAfterHours));
const hasStaleThreshold = computed(
() => Number.isFinite(staleAfterHours.value) && staleAfterHours.value > 0
);
const isStale = computed(
() =>
hasStaleThreshold.value &&
ageInHours.value !== null &&
ageInHours.value >= staleAfterHours.value
);
const errorLabel = computed(() =>
t(ERROR_CODE_LABELS[props.errorCode] || DEFAULT_ERROR_LABEL)
);
const label = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED');
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const fullLabel = computed(() => {
if (isSyncing.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCING');
if (isStaleSync.value) return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.STALE_SYNC');
if (isFailed.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.FAILED', {
error: errorLabel.value,
});
if (hasBeenSynced.value)
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.SYNCED', {
time: dynamicTime(props.lastSyncedAt),
});
return t('CAPTAIN.DOCUMENTS.SYNC_STATUS.NEVER_SYNCED');
});
const tone = computed(() => {
if (isSyncing.value) return 'amber';
if (isStaleSync.value) return 'amber';
if (isFailed.value) return 'ruby';
if (isStale.value) return 'amber';
return 'slate';
});
const textClass = computed(() => {
if (tone.value === 'amber') return 'text-n-amber-11';
if (tone.value === 'ruby') return 'text-n-ruby-11';
return 'text-n-slate-11';
});
const statusIcon = computed(() => {
if (isFailed.value || isStale.value || isStaleSync.value) {
return 'i-lucide-circle-alert';
}
return 'i-lucide-refresh-cw';
});
</script>
<template>
<span
class="flex gap-1.5 items-center text-sm truncate shrink-0 tabular-nums"
:class="textClass"
:title="fullLabel"
>
<Spinner v-if="isSyncing" class="text-n-amber-11 size-3" />
<Icon v-else :icon="statusIcon" class="shrink-0 size-3.5" />
<span class="truncate">{{ label }}</span>
<Button
v-if="showRetry && canRetry"
:label="t('CAPTAIN.DOCUMENTS.OPTIONS.RETRY_SYNC')"
xs
link
ruby
icon="i-lucide-refresh-cw"
class="hover:!no-underline !gap-1 ms-1"
@click.stop="emit('retry')"
/>
</span>
</template>
@@ -15,7 +15,7 @@ defineProps({
},
});
const emit = defineEmits(['close', 'createSuccess']);
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
@@ -26,7 +26,6 @@ const i18nKey = 'CAPTAIN.DOCUMENTS.CREATE';
const handleSubmit = async newDocument => {
try {
await store.dispatch('captainDocuments/create', newDocument);
emit('createSuccess');
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import FormattedContent from './Text/FormattedContent.vue';
import { useI18n } from 'vue-i18n';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { useMessageContext } from '../provider.js';
@@ -42,8 +41,7 @@ const starRatingValue = computed(() => {
<template>
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
<FormattedContent v-if="content" :content="content" />
<h4 v-else>{{ t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
<dl v-if="isRatingSubmitted" class="mt-4">
<dt class="text-n-slate-11 italic">
{{ t('CONVERSATION.RATING_TITLE') }}
@@ -1,5 +1,4 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
@@ -31,16 +30,10 @@ const props = defineProps({
type: String,
default: '',
},
beta: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
emit('select', props.id);
@@ -49,14 +42,14 @@ const handleChange = () => {
</script>
<template>
<label
:for="id"
class="rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6 focus-within:has-[:focus-visible]:ring-2 focus-within:has-[:focus-visible]:ring-n-strong"
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
@@ -65,7 +58,6 @@ const handleChange = () => {
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
<Label v-if="beta" :label="t('GENERAL.BETA')" color="blue" compact />
</div>
<input
:id="`${id}`"
@@ -74,7 +66,7 @@ const handleChange = () => {
:name="id"
:disabled="disabled"
type="radio"
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
@change="handleChange"
/>
</div>
@@ -83,5 +75,5 @@ const handleChange = () => {
</p>
<slot />
</div>
</label>
</div>
</template>
@@ -803,11 +803,6 @@ watch(
}
);
watch(
computed(() => props.disabled),
() => editorView?.setProps({})
);
watch(
computed(() => props.updateSelectionWith),
(newValue, oldValue) => {
@@ -7,7 +7,6 @@ import {
ArticleMarkdownTransformer,
EditorState,
Selection,
imageResizeView,
} from '@chatwoot/prosemirror-schema';
import {
suggestionsPlugin,
@@ -236,7 +235,6 @@ export default {
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
editorView.dispatch(tr.scrollIntoView());
},
imageUpload: () => this.openFileBrowser(),
};
const command = commandMap[actionKey];
@@ -334,9 +332,6 @@ export default {
createEditorView() {
editorView = new EditorView(this.$refs.editor, {
state: state,
nodeViews: {
image: imageResizeView,
},
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -60,12 +60,6 @@ const EDITOR_ACTIONS = [
icon: 'i-lucide-table',
menuKey: 'insertTable',
},
{
value: 'imageUpload',
labelKey: 'SLASH_COMMANDS.IMAGE',
icon: 'i-lucide-image',
menuKey: 'imageUpload',
},
{
value: 'strike',
labelKey: 'SLASH_COMMANDS.STRIKETHROUGH',
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import ReplyToMessage from './ReplyToMessage.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
@@ -143,6 +144,8 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -381,8 +384,14 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel) {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
return false;
}
@@ -407,7 +416,11 @@ export default {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return this.isAnEmailChannel && !this.isOnPrivateNote;
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
},
shouldShowQuotedPreview() {
return (
@@ -564,6 +577,7 @@ export default {
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
@@ -1,8 +1,9 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useTemplateRef, computed, ref, onMounted } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -18,6 +19,7 @@ const props = defineProps({
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
@@ -75,6 +77,10 @@ const handleDismiss = () => {
selectedTeam.value = null;
toggleDropdown(false);
};
onMounted(() => {
store.dispatch('teams/get');
});
</script>
<template>
+1 -1
View File
@@ -41,8 +41,8 @@ export const FEATURE_FLAGS = {
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
CAPTAIN_V2: 'captain_integration_v2',
CAPTAIN_TASKS: 'captain_tasks',
CAPTAIN_DOCUMENT_AUTO_SYNC: 'captain_document_auto_sync',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
CONVERSATION_REQUIRED_ATTRIBUTES: 'conversation_required_attributes',
-2
View File
@@ -7,7 +7,6 @@ import de from './locale/de';
import el from './locale/el';
import en from './locale/en';
import es from './locale/es';
import et from './locale/et';
import fa from './locale/fa';
import fi from './locale/fi';
import fr from './locale/fr';
@@ -50,7 +49,6 @@ export default {
el,
en,
es,
et,
fa,
fi,
fr,
@@ -10,8 +10,7 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact",
"COMPANY": "ኩባንያ"
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -109,8 +108,7 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact",
"COMPANY": "ኩባንያ"
"CONTACT": "Contact"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,7 +130,6 @@
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
},
"NONE_OPTION": "None",
"LAST_RESPONDING_AGENT": "Last Responding Agent",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
@@ -182,7 +181,6 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "ኩባንያ",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -6,12 +6,7 @@
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "None",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -43,8 +38,6 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -140,7 +140,6 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
"SENDING": "Sending"
}
}
@@ -7,7 +7,6 @@
"NAME": "Name",
"DOMAIN": "Domain",
"CREATED_AT": "Created at",
"LAST_ACTIVITY_AT": "Last activity",
"CONTACTS_COUNT": "Contacts count"
}
},
@@ -22,121 +21,6 @@
"LOADING": "Loading companies...",
"UNNAMED": "Unnamed Company",
"CONTACTS_COUNT": "{n} contact | {n} contacts",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "ባህሪያት",
"CONTACTS": "እውቂያዎች",
"HISTORY": "ታሪክ",
"NOTES": "ማስታወሻዎች"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "Search attributes...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "Loading contacts...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "እውቂያ አክል",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "እውቂያዎችን ይፈልጉ...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "አንድም እውቂያ አልተገኘም.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "ኩባንያ",
"CONTACT_LABEL": "እውቂያ",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "Cancel"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "ተፈጥሯል {date}",
"LAST_ACTIVE": "መጨረሻ እንቅስቃሴ {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "Name",
"DOMAIN": "ዶሜይን"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "No companies found"
}
@@ -386,7 +386,6 @@
"IDENTIFIER": "መለያ",
"COUNTRY": "አገር",
"CITY": "ከተማ",
"COMPANY": "ኩባንያ",
"CREATED_AT": "ተፈጥሯል በ",
"LAST_ACTIVITY": "መጨረሻ እንቅስቃሴ",
"REFERER_LINK": "የመግቢያ አገናኝ አገናኝ",
@@ -71,8 +71,7 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
"HIDE_LABELS": "Hide labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -83,9 +82,7 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "Join call"
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "ተፈትኗል",
@@ -95,7 +92,6 @@
"OPEN": "ተጨማሪ",
"CLOSE": "ዝጋ",
"DETAILS": "ዝርዝሮች",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
@@ -366,19 +362,7 @@
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "የተገናኙ የLinear ጉዳዮች",
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -525,7 +525,6 @@
"PUBLISH": "ለህትመት አድርግ",
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate",
"DELETE": "ሰርዝ"
},
"STATUS": {
@@ -580,41 +579,6 @@
"TITLE": "በዚህ ምድብ ምንም ጽሑፎች የሉም",
"SUBTITLE": "በዚህ ምድብ ያሉ ጽሑፎች እዚህ ይታያሉ"
}
},
"BULK_TRANSLATE": {
"TITLE": "Translate article | Translate {count} articles",
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
"LOCALE_LABEL": "Target language",
"LOCALE_PLACEHOLDER": "Select a language",
"CATEGORY_LABEL": "Target category",
"CATEGORY_PLACEHOLDER": "Select a category",
"OPTIONAL": "(optional)",
"CONFIRM": "Translate",
"SELECT_ALL": "ሁሉንም ይምረጡ ({count})",
"SELECTED_COUNT": "{count} ተመረጡ",
"CLEAR_SELECTION": "ምርጫ አጽዳ",
"TRANSLATE_BUTTON": "Translate",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "አስተዋውቅ",
"DRAFT": "እቅድ",
"ARCHIVE": "አርክቭ",
"TRANSLATE": "Translate",
"DELETE": "Delete",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "Delete",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,17 +636,7 @@
"WIDGET_BUILDER": "የዊጅት አሰራር መሣሪያ",
"BOT_CONFIGURATION": "የቦት ቅንብሮች",
"ACCOUNT_HEALTH": "የመለያ ጤና",
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)",
"VOICE": "ድምጽ"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)"
},
"CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች",
"WIDGET_FEATURES": "የዊጅት ባህሪያት",
@@ -755,7 +745,6 @@
"SENDER_NAME_SECTION_TEXT": "በኢሜይል ውስጥ የAgent ስም እንዲታይ/እንዳይታይ አርግ፣ ካልተከናወነ የንግድ ስም ይታያል",
"ENABLE_CONTINUITY_VIA_EMAIL": "በኢሜል የውይይት ቀጥታነት አንቀሳቅስ",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "ከኮንታክት ኢሜይል አድራሻ ካለ ውይይቶች በኢሜይል ይቀጥላሉ።",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
"LOCK_TO_SINGLE_CONVERSATION": "የውይይት መላኪያ",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "ለአሁን ያሉ እውቂያዎች ውይይት ፍጠራ ያስተካክሉ",
"INBOX_UPDATE_TITLE": "የኢንቦክስ ቅንብሮች",
@@ -1011,8 +1000,7 @@
"LABEL": "የይለፍ ቃል",
"PLACE_HOLDER": "የይለፍ ቃል"
},
"ENABLE_SSL": "SSL አንቀሳቅስ",
"AUTH_MECHANISM": "ማረጋገጫ"
"ENABLE_SSL": "SSL አንቀሳቅስ"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -8,7 +8,6 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -27,8 +26,6 @@ import integrations from './integrations.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
@@ -50,7 +47,6 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -69,8 +65,6 @@ export default {
...labelsMgmt,
...login,
...macros,
...mfa,
...onboarding,
...report,
...resetPassword,
...search,
@@ -46,7 +46,6 @@
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Create",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "Cancel"
},
"API": {
@@ -742,7 +742,6 @@
"SELECT_ALL": "ሁሉንም ይምረጡ ({count})",
"UNSELECT_ALL": "ሁሉንም አልምረጥም ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_SYNC_BUTTON": "Refresh",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -750,51 +749,6 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "አልተሳካም"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "updating...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "Page not found",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "ተዛማጅ የFAQ ጥያቄዎች",
"DESCRIPTION": "እነዚህ የFAQ ጥያቄዎች ቀጥተኛ ከሰነዱ ተፈጥረዋል።"
@@ -838,15 +792,11 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "ተዛማጅ ምላሾችን እይ",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "ሰነድ ሰርዝ"
},
"EMPTY_STATE": {
"TITLE": "ሰነዶች አልተገኙም",
"SUBTITLE": "ሰነዶች በእርስዎ አገልጋይ በተጠቃሚ ጥያቄዎች ላይ የሚሰጥ የተደጋጋሚ ጥያቄዎችን ለመፍጠር ይጠቀማሉ። ሰነዶችን ለአገልጋይዎ እውነተኛ እይታ ለማቅረብ ማስገባት ይችላሉ።",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain ሰነድ",
"NOTE": "በCaptain ውስጥ ሰነድ እንደ ለማድረግ ምንጭ እንደሚሰራ አገልግሎት አገልግሎት ነው። የእርዳታ ማዕከላችሁን ወይም መምሪያዎችን በመገናኘት፣ Captain ይዘቱን ማብራሪያ ማድረግ እና ለደንበኞች ጥያቄዎች ትክክለኛ ምላሾችን ማቅረብ ይችላል።"
@@ -49,9 +49,6 @@
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "Edit macro",
"API": {
@@ -69,9 +66,7 @@
"LABEL": "Macro Visibility",
"GLOBAL": {
"LABEL": "Public",
"DESCRIPTION": "This macro is available publicly for all agents in this account.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
"DESCRIPTION": "This macro is available publicly for all agents in this account."
},
"PERSONAL": {
"LABEL": "Private",
@@ -55,10 +55,6 @@
"PASSWORD": "Password",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "Cancel",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -1,34 +0,0 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "Email",
"YOUR_ROLE": "Your Role",
"WEBSITE": "Website",
"LANGUAGE": "Language",
"TIMEZONE": "Timezone",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "የሰዓት ክልል ይምረጡ",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "ቀጥል",
"SAVING": "እየተቀማጭ ነው...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -10,8 +10,7 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال",
"COMPANY": "المنشأة"
"CONTACT": "جهات الاتصال"
},
"ATTRIBUTE_TYPES": {
"TEXT": "النص",
@@ -109,8 +108,7 @@
"TABS": {
"HEADER": "صفات مخصصة",
"CONVERSATION": "المحادثات",
"CONTACT": "جهات الاتصال",
"COMPANY": "المنشأة"
"CONTACT": "جهات الاتصال"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,7 +130,6 @@
"ATLEAST_ONE_ACTION_REQUIRED": "إجراء واحد على الأقل مطلوب"
},
"NONE_OPTION": "لا شيء",
"LAST_RESPONDING_AGENT": "آخر وكيل قام بالرد",
"EVENTS": {
"CONVERSATION_CREATED": "تم إنشاء المحادثة",
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
@@ -182,7 +181,6 @@
"BROWSER_LANGUAGE": "لغة المتصفح",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "الدولة",
"COMPANY_NAME": "المنشأة",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "المكلَّف",
"TEAM_NAME": "الفريق",
@@ -6,12 +6,7 @@
"UNASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من إلغاء تعيين {conversationCount} {conversationLabel}؟",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"NONE": "لا شيء",
"CLEAR_SELECTION": "مسح",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "نعم",
"CANCEL": "إلغاء",
"SEARCH_INPUT_PLACEHOLDER": "بحث",
"ASSIGN_AGENT_TOOLTIP": "تعيين وكيل",
"ASSIGN_TEAM_TOOLTIP": "تعيين فريق",
@@ -43,8 +38,6 @@
"NONE": "لا شيء",
"NO_TEAMS_AVAILABLE": "لا توجد فرق مضافة إلى هذا الحساب حتى الآن.",
"ASSIGN_SELECTED_TEAMS": "تعيين فريق محدد.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "تم تعيين الفرق بنجاح.",
"ASSIGN_FAILED": "فشل تعيين الفريق، الرجاء المحاولة مرة أخرى."
}
@@ -140,7 +140,6 @@
"HIDE_QUOTED_TEXT": "إخفاء النص المقتبس",
"SHOW_QUOTED_TEXT": "إظهار النص المقتبس",
"MESSAGE_READ": "قراءة",
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
"SENDING": "Sending"
}
}
@@ -7,7 +7,6 @@
"NAME": "الاسم",
"DOMAIN": "النطاق",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY_AT": "آخر نشاط",
"CONTACTS_COUNT": "عدد جهات الاتصال"
}
},
@@ -22,121 +21,6 @@
"LOADING": "جاري تحميل الشركات...",
"UNNAMED": "شركة بلا اسم",
"CONTACTS_COUNT": "جهة اتصال {n} | {n} جهات الاتصال",
"ACTIONS": {
"CREATE": "Add company"
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
"SAVE": "Add company"
},
"MESSAGES": {
"SUCCESS": "Company created.",
"ERROR": "Could not create the company."
}
},
"DETAIL": {
"LOADING": "Loading company details...",
"EMPTY_STATE": {
"TITLE": "Company not found",
"SUBTITLE": "This company may have been removed or is no longer available in this account."
},
"SIDEBAR": {
"TABS": {
"ATTRIBUTES": "السمات",
"CONTACTS": "جهات الاتصال",
"HISTORY": "History",
"NOTES": "ملاحظات"
}
},
"HISTORY": {
"EMPTY": "No conversations found for this company's contacts yet."
},
"NOTES": {
"EMPTY": "No notes found for this company's contacts yet."
},
"ATTRIBUTES": {
"SEARCH_PLACEHOLDER": "البحث عن صفات...",
"EMPTY_STATE": "There are no company custom attributes configured yet.",
"NO_ATTRIBUTES": "No matching attributes found.",
"UNUSED_ATTRIBUTES": "{count} unused attribute | {count} unused attributes",
"MESSAGES": {
"UPDATE_SUCCESS": "Company attribute updated.",
"UPDATE_ERROR": "Could not update company attribute.",
"DELETE_SUCCESS": "Company attribute removed.",
"DELETE_ERROR": "Could not remove company attribute."
}
},
"CONTACTS": {
"LOADING": "جاري جلب جهات الاتصال...",
"EMPTY": "No contacts are linked to this company yet.",
"UNNAMED_CONTACT": "Unnamed contact",
"ACTIONS": {
"ADD": "Add contact",
"REMOVE": "Remove contact"
},
"DIALOGS": {
"ADD": {
"DESCRIPTION": "Search for an existing contact and link it to this company.",
"SEARCH_PLACEHOLDER": "Search contacts...",
"INITIAL": "Start typing to search contacts.",
"EMPTY": "No contacts found.",
"CONFIRM_TITLE": "Link contact",
"CONFIRM_DESCRIPTION": "Confirm the company and contact before linking them.",
"COMPANY_LABEL": "المنشأة",
"CONTACT_LABEL": "جهات الاتصال",
"CURRENT_COMPANY": "Currently linked to {companyName}",
"ADD": "Link contact",
"CANCEL": "إلغاء"
}
},
"MESSAGES": {
"ADD_SUCCESS": "Contact linked to company.",
"ADD_ERROR": "Could not link contact to company.",
"REASSIGN_SUCCESS": "Contact reassigned to company.",
"REASSIGN_ERROR": "Could not reassign contact to company.",
"REMOVE_SUCCESS": "Contact removed from company.",
"REMOVE_ERROR": "Could not remove contact from company."
}
},
"AVATAR": {
"UPDATING": "Updating company avatar...",
"UPLOAD_SUCCESS": "Company avatar updated.",
"UPLOAD_ERROR": "Could not update the company avatar.",
"DELETE_SUCCESS": "Company avatar removed.",
"DELETE_ERROR": "Could not remove the company avatar."
},
"PROFILE": {
"TITLE": "Edit company details",
"CREATED_AT": "Created {date}",
"LAST_ACTIVE": "Last active {date}",
"DESCRIPTION_PLACEHOLDER": "Add a short description for this company",
"ACTIONS": {
"SAVE": "Update company"
},
"MESSAGES": {
"UPDATE_SUCCESS": "Company updated.",
"UPDATE_ERROR": "Could not update the company."
},
"FIELDS": {
"NAME": "الاسم",
"DOMAIN": "النطاق"
}
},
"DELETE": {
"SECTION_TITLE": "Danger zone",
"SECTION_DESCRIPTION": "Delete this company and unlink its contacts. The contacts will remain in the account.",
"BUTTON": "Delete company",
"TITLE": "Delete company?",
"DESCRIPTION": "This will remove the company and unlink all associated contacts. Contacts themselves will be preserved.",
"DESCRIPTION_WITH_NAME": "This will remove {companyName} and unlink all associated contacts. Contacts themselves will be preserved.",
"CONFIRM": "Delete company",
"MESSAGES": {
"SUCCESS": "Company deleted.",
"ERROR": "Could not delete the company."
}
}
},
"EMPTY_STATE": {
"TITLE": "لم يتم العثور على شركات"
}
@@ -386,7 +386,6 @@
"IDENTIFIER": "المعرف",
"COUNTRY": "الدولة",
"CITY": "المدينة",
"COMPANY": "المنشأة",
"CREATED_AT": "تم إنشاؤها في",
"LAST_ACTIVITY": "آخر نشاط",
"REFERER_LINK": "رابط المرجع",
@@ -71,8 +71,7 @@
"REPLY_MESSAGE_NOT_FOUND": "الرسالة غير متوفرة",
"CARD": {
"SHOW_LABELS": "إظهار السمات",
"HIDE_LABELS": "إخفاء السمات",
"LABELS_COUNT": "{count} علامة"
"HIDE_LABELS": "إخفاء السمات"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -83,9 +82,7 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
"YOU_ANSWERED": "You answered",
"AGENT_ANSWERED": "{agentName} answered",
"JOIN_CALL": "انضم إلى المكالمة"
"YOU_ANSWERED": "You answered"
},
"HEADER": {
"RESOLVE_ACTION": "حل المحادثة",
@@ -95,7 +92,6 @@
"OPEN": "المزيد",
"CLOSE": "أغلق",
"DETAILS": "التفاصيل",
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
"SNOOZED_UNTIL": "غفوة حتى",
"SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد",
"SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم",
@@ -366,19 +362,7 @@
"PREVIOUS_CONVERSATION": "المحادثات السابقة",
"MACROS": "ماكروس",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders",
"SHARED_FILES": "Attachments"
},
"SHARED_FILES": {
"EMPTY": "No attachments yet",
"DOWNLOAD": "Download file",
"DOWNLOAD_ERROR": "Could not download the file. Please try again.",
"MEDIA_HEADING": "Media",
"FILES_HEADING": "Files",
"VIEW_ALL": "عرض الكل",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file"
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -525,7 +525,6 @@
"PUBLISH": "نشر",
"DRAFT": "مسودة",
"ARCHIVE": "Archive",
"TRANSLATE": "ترجم",
"DELETE": "حذف"
},
"STATUS": {
@@ -580,41 +579,6 @@
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
}
},
"BULK_TRANSLATE": {
"TITLE": "ترجمة المقالة | ترجمة {count} مقالة",
"DESCRIPTION": "ترجمة المقالة المحددة إلى لغة أخرى. | ترجمة المقالات المحددة إلى لغة أخرى.",
"LOCALE_LABEL": "اللغة المستهدفة",
"LOCALE_PLACEHOLDER": "اختر لغة",
"CATEGORY_LABEL": "الفئة المستهدفة",
"CATEGORY_PLACEHOLDER": "اختر الفئة",
"OPTIONAL": "(اختياري)",
"CONFIRM": "ترجم",
"SELECT_ALL": "Select all ({count})",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"TRANSLATE_BUTTON": "ترجم",
"CONFIRM_OVERWRITE": "Overwrite and translate",
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
"API": {
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
"ERROR_MESSAGE": "Failed to start translation. Please try again."
}
},
"BULK_ACTIONS": {
"PUBLISH": "نشر",
"DRAFT": "مسودة",
"ARCHIVE": "Archive",
"TRANSLATE": "ترجم",
"DELETE": "حذف",
"STATUS_SUCCESS": "Articles updated successfully",
"STATUS_ERROR": "Failed to update articles",
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
"DELETE_CONFIRM": "حذف",
"DELETE_SUCCESS": "Articles deleted successfully",
"DELETE_ERROR": "Failed to delete articles"
}
},
"CATEGORY_PAGE": {
@@ -636,17 +636,7 @@
"WIDGET_BUILDER": "منشئ اللايف شات",
"BOT_CONFIGURATION": "اعدادات البوت",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "تقييم رضاء العملاء",
"VOICE": "Voice"
},
"VOICE_CONFIGURATION": {
"ENABLE_VOICE": {
"LABEL": "Enable Voice Calling",
"DESCRIPTION": "Enable voice calling on this inbox. Agents will be able to make and receive phone calls."
},
"CREDENTIALS": {
"DESCRIPTION": "Voice calling requires Twilio API Key credentials. These are used to generate tokens for agent voice connections."
}
"CSAT": "تقييم رضاء العملاء"
},
"CHANNEL_PREFERENCES": "Channel Preferences",
"WIDGET_FEATURES": "Widget features",
@@ -755,7 +745,6 @@
"SENDER_NAME_SECTION_TEXT": "تمكين/تعطيل إظهار اسم الوكيل في البريد الإلكتروني، إذا تم تعطيله فسيظهر اسم المنشأة",
"ENABLE_CONTINUITY_VIA_EMAIL": "تمكين استمرارية المحادثة عبر البريد الإلكتروني",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "المحادثات ستستمر عبر البريد الإلكتروني إذا كان عنوان البريد الإلكتروني لجهة الاتصال متاحاً.",
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "هذه الميزة متوفرة في الخطة المدفوعة. قم بالترقية لتفعيل استمرارية المحادثة عبر البريد الإلكتروني.",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "إعدادات قناة التواصل",
@@ -1011,8 +1000,7 @@
"LABEL": "كلمة المرور",
"PLACE_HOLDER": "كلمة المرور"
},
"ENABLE_SSL": "تمكين SSL",
"AUTH_MECHANISM": "المصادقة"
"ENABLE_SSL": "تمكين SSL"
},
"MICROSOFT": {
"TITLE": "Microsoft",
@@ -8,7 +8,6 @@ import bulkActions from './bulkActions.json';
import campaign from './campaign.json';
import cannedMgmt from './cannedMgmt.json';
import chatlist from './chatlist.json';
import companies from './companies.json';
import components from './components.json';
import contact from './contact.json';
import contactFilters from './contactFilters.json';
@@ -27,8 +26,6 @@ import integrations from './integrations.json';
import labelsMgmt from './labelsMgmt.json';
import login from './login.json';
import macros from './macros.json';
import mfa from './mfa.json';
import onboarding from './onboarding.json';
import report from './report.json';
import resetPassword from './resetPassword.json';
import search from './search.json';
@@ -50,7 +47,6 @@ export default {
...campaign,
...cannedMgmt,
...chatlist,
...companies,
...components,
...contact,
...contactFilters,
@@ -69,8 +65,6 @@ export default {
...labelsMgmt,
...login,
...macros,
...mfa,
...onboarding,
...report,
...resetPassword,
...search,
@@ -46,7 +46,6 @@
"PLACEHOLDER": "اختر صندوق الوارد"
},
"SUBMIT": "إنشاء",
"VALIDATING_OPENAI": "Validating with OpenAI...",
"CANCEL": "إلغاء"
},
"API": {
@@ -742,7 +742,6 @@
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "حذف",
"BULK_SYNC_BUTTON": "تحديث",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
@@ -750,51 +749,6 @@
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"BULK_SYNC": {
"SUCCESS_MESSAGE_ONE": "Refresh queued for 1 document",
"SUCCESS_MESSAGE": "Refresh queued for {count} documents",
"ZERO_MESSAGE": "No documents marked for refresh.",
"ERROR_MESSAGE": "There was an error queuing the refresh, please try again."
},
"SYNC": {
"QUEUED_MESSAGE": "Refresh queued. We'll update the document shortly.",
"ERROR_MESSAGE": "Could not queue refresh, please try again."
},
"FILTERS": {
"SOURCE": {
"ALL": "All sources",
"WEB": "Web pages",
"PDF": "PDFs"
},
"STATUS": {
"ANY": "Any status",
"UPDATED": "Updated",
"NEEDS_UPDATE": "Needs update",
"UPDATING": "Updating",
"FAILED": "Failed"
},
"SORT": {
"RECENTLY_UPDATED": "Recently updated",
"RECENTLY_CREATED": "Recently created"
},
"SEARCH_PLACEHOLDER": "Search..."
},
"SYNC_STATUS": {
"SYNCED": "last updated {time}",
"SYNCING": "جاري التحديث...",
"STALE_SYNC": "update stalled",
"FAILED": "Failed to sync",
"NEVER_SYNCED": "not updated yet"
},
"SYNC_ERRORS": {
"NOT_FOUND": "لم يتم العثور على الصفحة",
"ACCESS_DENIED": "Access denied",
"TIMEOUT": "Page took too long to respond",
"CONTENT_EMPTY": "Page returned empty content",
"FETCH_FAILED": "Could not fetch page",
"SYNC_ERROR": "Unexpected error",
"DEFAULT": "Sync error"
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -838,15 +792,11 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
},
"EMPTY_STATE": {
"TITLE": "No documents available",
"SUBTITLE": "Documents are used by your assistant to generate FAQs. You can import documents to provide context for your assistant.",
"FILTERED_TITLE": "No matching documents",
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Captain Document",
"NOTE": "A document in Captain serves as a knowledge resource for the assistant. By connecting your help center or guides, Captain can analyze the content and provide accurate responses for customer inquiries."
@@ -49,9 +49,6 @@
"ERROR_MESSAGE": "حدث خطأ أثناء حذف الماكرو. الرجاء المحاولة مرة أخرى في وقت لاحق"
}
},
"VIEW": {
"TOOLTIP": "View macro"
},
"EDIT": {
"TOOLTIP": "تعديل الماكرو",
"API": {
@@ -69,9 +66,7 @@
"LABEL": "الرؤية الخاصة بالماكرو",
"GLOBAL": {
"LABEL": "عامة",
"DESCRIPTION": "هذا الماكرو متاح بشكل عام لجميع الوكلاء في هذا الحساب.",
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
"DESCRIPTION": "هذا الماكرو متاح بشكل عام لجميع الوكلاء في هذا الحساب."
},
"PERSONAL": {
"LABEL": "خاص",
@@ -55,10 +55,6 @@
"PASSWORD": "كلمة المرور",
"OTP_CODE": "Verification Code",
"OTP_CODE_PLACEHOLDER": "000000",
"BACKUP_CODE": "Backup Code",
"BACKUP_CODE_PLACEHOLDER": "Enter one of your backup codes",
"USE_BACKUP_CODE": "Lost access to your authenticator? Use a backup code instead",
"USE_OTP_CODE": "Use a verification code from your authenticator app",
"CONFIRM": "Disable 2FA",
"CANCEL": "إلغاء",
"SUCCESS": "Two-factor authentication has been disabled",
@@ -1,34 +0,0 @@
{
"ONBOARDING_NEXT": {
"GREETING": "Hello {name}!",
"SUBTITLE": "Please review the following details",
"YOUR_DETAILS": "Your details",
"COMPANY_DETAILS": "Company details",
"FIELDS": {
"EMAIL": "البريد الإلكتروني",
"YOUR_ROLE": "Your Role",
"WEBSITE": "الموقع الإلكتروني",
"LANGUAGE": "اللغة",
"TIMEZONE": "منطقة زمنية",
"COMPANY_SIZE": "Company Size",
"INDUSTRY": "Industry",
"REFERRAL_SOURCE": "Where did you find us?"
},
"PLACEHOLDERS": {
"SELECT_ROLE": "Select your role",
"ENTER_WEBSITE": "www.example.com",
"SELECT_LANGUAGE": "Select language",
"SELECT_TIMEZONE": "اختر المنطقة الزمنية",
"SELECT_COMPANY_SIZE": "Select company size",
"SELECT_INDUSTRY": "Select industry",
"SELECT_REFERRAL_SOURCE": "Select source"
},
"EMAIL_VERIFIED": "Email verified",
"SETTING_UP": "Setting up your account...",
"CONTINUE": "Continue",
"SAVING": "جاري الحفظ...",
"VALIDATION_ERROR": "Please fill in all required fields",
"SUCCESS": "Details saved successfully",
"ERROR": "Could not save details. Please try again."
}
}
@@ -10,8 +10,7 @@
"NO_RESULTS": "No attributes found matching your search",
"ATTRIBUTE_MODELS": {
"CONVERSATION": "Conversation",
"CONTACT": "Contact",
"COMPANY": "Şirkət"
"CONTACT": "Contact"
},
"ATTRIBUTE_TYPES": {
"TEXT": "Text",
@@ -109,8 +108,7 @@
"TABS": {
"HEADER": "Custom Attributes",
"CONVERSATION": "Conversation",
"CONTACT": "Contact",
"COMPANY": "Şirkət"
"CONTACT": "Contact"
},
"LIST": {
"TABLE_HEADER": {
@@ -130,7 +130,6 @@
"ATLEAST_ONE_ACTION_REQUIRED": "At least one action is required"
},
"NONE_OPTION": "None",
"LAST_RESPONDING_AGENT": "Last Responding Agent",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
@@ -182,7 +181,6 @@
"BROWSER_LANGUAGE": "Browser Language",
"MAIL_SUBJECT": "Email Subject",
"COUNTRY_NAME": "Country",
"COMPANY_NAME": "Şirkət",
"REFERER_LINK": "Referrer Link",
"ASSIGNEE_NAME": "Assignee",
"TEAM_NAME": "Team",
@@ -6,12 +6,7 @@
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"NONE": "Heç biri",
"CLEAR_SELECTION": "Clear",
"ASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {agentName}? | Are you sure you want to assign {n} conversations to {agentName}?",
"UNASSIGN_AGENT_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -43,8 +38,6 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to assign {n} conversation to {teamName}? | Are you sure you want to assign {n} conversations to {teamName}?",
"UNASSIGN_TEAM_CONFIRMATION_LABEL": "Are you sure you want to unassign {n} conversation? | Are you sure you want to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -140,7 +140,6 @@
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read",
"SENDING": "Sending",
"UNREAD_COUNT_OVERFLOW": "9+"
"SENDING": "Sending"
}
}

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