Merge branch 'develop' into fix/CW-7007
This commit is contained in:
+18
-4
@@ -2,8 +2,22 @@
|
||||
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+)
|
||||
# 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.
|
||||
# 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.
|
||||
- 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
|
||||
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 18
|
||||
parallelism: 20
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -133,9 +133,9 @@ gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
gem 'sidekiq', '~> 7.3', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
gem 'sidekiq-cron', '>= 2.4.0'
|
||||
# for sidekiq healthcheck
|
||||
gem 'sidekiq_alive'
|
||||
|
||||
|
||||
+17
-12
@@ -196,6 +196,9 @@ 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)
|
||||
@@ -212,7 +215,7 @@ GEM
|
||||
logger
|
||||
msgpack
|
||||
datadog-ruby_core_source (3.4.1)
|
||||
date (3.4.1)
|
||||
date (3.5.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -574,7 +577,7 @@ GEM
|
||||
uri (>= 0.11.1)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.20)
|
||||
net-imap (0.4.24)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -590,14 +593,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.19.1)
|
||||
nokogiri (1.19.3)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-arm64-darwin)
|
||||
nokogiri (1.19.3-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-darwin)
|
||||
nokogiri (1.19.3-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
nokogiri (1.19.3-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -902,10 +905,11 @@ GEM
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.22.2)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
sidekiq-cron (2.4.0)
|
||||
cronex (>= 0.13.0)
|
||||
fugit (~> 1.8, >= 1.11.1)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6)
|
||||
sidekiq (>= 6.5.0)
|
||||
sidekiq_alive (2.5.0)
|
||||
gserver (~> 0.0.1)
|
||||
sidekiq (>= 5, < 9)
|
||||
@@ -961,7 +965,7 @@ GEM
|
||||
time_diff (0.3.0)
|
||||
activesupport
|
||||
i18n
|
||||
timeout (0.4.3)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
twilio-ruby (7.6.0)
|
||||
faraday (>= 0.9, < 3.0)
|
||||
@@ -979,6 +983,7 @@ 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)
|
||||
@@ -1155,8 +1160,8 @@ DEPENDENCIES
|
||||
sentry-sidekiq (>= 5.19.0)
|
||||
shopify_api
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq (~> 7.3, >= 7.3.1)
|
||||
sidekiq-cron (>= 2.4.0)
|
||||
sidekiq_alive
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.13.0
|
||||
4.14.0
|
||||
|
||||
@@ -28,6 +28,10 @@ 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)
|
||||
|
||||
@@ -27,6 +27,8 @@ 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,
|
||||
@@ -36,4 +38,17 @@ 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
|
||||
|
||||
@@ -31,13 +31,24 @@ class V2::Reports::BotMetricsBuilder
|
||||
end
|
||||
|
||||
def bot_resolutions_count
|
||||
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
|
||||
created_at: range).distinct.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
|
||||
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
|
||||
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)
|
||||
end
|
||||
|
||||
def bot_resolution_rate
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
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(:message)
|
||||
.includes({ file_attachment: :blob }, message: [:inbox, { sender: { avatar_attachment: :blob } }])
|
||||
.order(created_at: :desc)
|
||||
.page(attachment_params[:page])
|
||||
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||
|
||||
@@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
include Api::V1::Accounts::Concerns::WhatsappHealthManagement
|
||||
|
||||
def index
|
||||
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
|
||||
@inboxes = policy_scope(Current.account.inboxes)
|
||||
.includes(:channel, :portal, :working_hours, { avatar_attachment: :blob })
|
||||
.order_by_name
|
||||
end
|
||||
|
||||
def show; end
|
||||
@@ -85,7 +87,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def fetch_agent_bot
|
||||
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
|
||||
@agent_bot = AgentBot.accessible_to(Current.account).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, type: :read)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, type: :read)
|
||||
else
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
|
||||
::Notification::DeleteNotificationJob.perform_later(Current.user, Current.account, 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.find(params[:id])
|
||||
@notification = current_user.notifications.where(account_id: Current.account.id).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
|
||||
process_attached_logo if params[:blob_id].present?
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -78,7 +78,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived,
|
||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -87,7 +89,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 = Inbox.find(permitted_params[:inbox_id])
|
||||
inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
return {} unless inbox.web_widget?
|
||||
|
||||
{ channel_web_widget_id: inbox.channel.id }
|
||||
@@ -98,6 +100,8 @@ 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)
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role, :website)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -8,7 +8,8 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription = current_user.notification_subscriptions
|
||||
.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy! if notification_subscription.present?
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -83,6 +83,10 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def set_message
|
||||
@message = @web_widget.inbox.messages.find(permitted_params[:id])
|
||||
# `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])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,10 @@ class Public::Api::V1::InboxesController < PublicController
|
||||
def set_conversation
|
||||
return if params[:conversation_id].blank?
|
||||
|
||||
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
@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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index]
|
||||
before_action :ensure_custom_domain_request, only: [:show, :index, :show_markdown]
|
||||
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]
|
||||
before_action :set_article, only: [:show, :show_markdown]
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@@ -21,6 +23,13 @@ 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
|
||||
@@ -62,7 +71,6 @@ 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,6 +7,8 @@ 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
|
||||
@@ -17,6 +19,14 @@ 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
|
||||
@@ -42,7 +52,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
article_locale = if article.category.present?
|
||||
article.category.locale
|
||||
else
|
||||
article.portal.default_locale
|
||||
article.locale
|
||||
end
|
||||
@locale = validate_and_get_locale(article_locale)
|
||||
I18n.with_locale(@locale, &)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
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
|
||||
@categories = @portal.categories.order(position: :asc)
|
||||
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
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -21,4 +27,9 @@ 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,7 +2,10 @@ 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
|
||||
@@ -28,4 +31,28 @@ 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, notice: "App Configs - #{@config.titleize} updated successfully"
|
||||
redirect_to super_admin_settings_path, flash: success_flash
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,6 +58,21 @@ 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,6 +25,29 @@ 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`
|
||||
@@ -42,6 +65,20 @@ 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
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
class SwaggerController < ApplicationController
|
||||
def respond
|
||||
if Rails.env.development? || Rails.env.test?
|
||||
render inline: Rails.root.join('swagger', derived_path).read
|
||||
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
|
||||
else
|
||||
head :not_found
|
||||
end
|
||||
@@ -11,8 +16,8 @@ class SwaggerController < ApplicationController
|
||||
|
||||
def derived_path
|
||||
params[:path] ||= 'index.html'
|
||||
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 = 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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,6 +17,7 @@ module FileTypeHelper
|
||||
def image_file?(content_type)
|
||||
[
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/bmp',
|
||||
|
||||
@@ -96,4 +96,15 @@ 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
|
||||
|
||||
@@ -53,13 +53,12 @@ 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)
|
||||
scope.reporting_events.where(account_id: account.id, name: :conversation_bot_resolved, created_at: range)
|
||||
.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||
end
|
||||
|
||||
def bot_handoffs
|
||||
@@ -67,6 +66,10 @@ 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,15 +6,22 @@ class CaptainDocument extends ApiClient {
|
||||
super('captain/documents', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, searchKey, assistantId } = {}) {
|
||||
get({ page = 1, searchKey, assistantId, filter, source, sort } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
searchKey,
|
||||
search_key: searchKey,
|
||||
assistant_id: assistantId,
|
||||
filter,
|
||||
source,
|
||||
sort,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
sync(id) {
|
||||
return axios.post(`${this.url}/${id}/sync`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainDocument();
|
||||
|
||||
@@ -28,6 +28,14 @@ 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,9 +56,14 @@ const closeMobileSidebar = () => {
|
||||
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
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"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -105,9 +110,14 @@ const closeMobileSidebar = () => {
|
||||
<div
|
||||
v-if="isSidebarOpen"
|
||||
id="details-sidebar-content"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -169,7 +169,7 @@ const handleContactSelect = contactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<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 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
|
||||
class="py-8 px-4 text-sm text-center rounded-xl border border-dashed border-n-strong 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"
|
||||
class="!px-0 before:hidden bg-transparent"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<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>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<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-10 px-6">
|
||||
<header class="sticky top-0 z-20 px-6">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
>
|
||||
|
||||
+1
-1
@@ -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"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
+7
-15
@@ -84,28 +84,20 @@ 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 = assignCategoryFromSlug(
|
||||
const categoryFromSlug = findCategoryFromSlug(
|
||||
categorySlugFromRoute.value
|
||||
);
|
||||
if (categoryFromSlug) return categoryFromSlug;
|
||||
}
|
||||
return selectedCategoryId.value
|
||||
? categories.value.find(
|
||||
category => category.id === selectedCategoryId.value
|
||||
)
|
||||
: categories.value[0] || null;
|
||||
return categories.value?.[0] || null;
|
||||
}
|
||||
return categories.value.find(
|
||||
category => category.id === props.article?.category?.id
|
||||
|
||||
+4
-1
@@ -168,7 +168,9 @@ const handlePageChange = page => emit('pageChange', page);
|
||||
const navigateToNewArticlePage = () => {
|
||||
const { categorySlug, locale } = route.params;
|
||||
router.push({
|
||||
name: 'portals_articles_new',
|
||||
name: props.isCategoryArticles
|
||||
? 'portals_categories_articles_new'
|
||||
: 'portals_articles_new',
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
@@ -274,6 +276,7 @@ watch(
|
||||
:categories="categories"
|
||||
:allowed-locales="allowedLocales"
|
||||
:has-selected-category="isCategoryArticles"
|
||||
@new-article="navigateToNewArticlePage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+8
-2
@@ -25,7 +25,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['localeChange']);
|
||||
const emit = defineEmits(['localeChange', 'newArticle']);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -179,7 +179,7 @@ const handleBreadcrumbClick = () => {
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<div v-else class="relative flex items-center gap-2">
|
||||
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.EDIT_CATEGORY')"
|
||||
@@ -196,6 +196,12 @@ 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>
|
||||
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
<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>
|
||||
+7
@@ -7,6 +7,7 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import PortalBaseSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue';
|
||||
import PortalConfigurationSettings from './PortalConfigurationSettings.vue';
|
||||
import PortalLayoutContentSettings from './PortalLayoutContentSettings.vue';
|
||||
import ConfirmDeletePortalDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/ConfirmDeletePortalDialog.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -102,6 +103,12 @@ const handleDeletePortal = () => {
|
||||
@send-cname-instructions="handleSendCnameInstructions"
|
||||
/>
|
||||
<div class="w-full h-px bg-n-weak" />
|
||||
<PortalLayoutContentSettings
|
||||
:active-portal="activePortal"
|
||||
:is-fetching="isFetching"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
/>
|
||||
<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">
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 818 B |
+18
@@ -0,0 +1,18 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -19,6 +19,7 @@ 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',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<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,14 +5,17 @@ import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import {
|
||||
isPdfDocument,
|
||||
isSafeHttpLink,
|
||||
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: {
|
||||
@@ -31,10 +34,38 @@ 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,
|
||||
@@ -64,6 +95,20 @@ 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 = [
|
||||
{
|
||||
@@ -74,7 +119,19 @@ const menuItems = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (checkPermissions(['administrator'])) {
|
||||
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) {
|
||||
allOptions.push({
|
||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.DELETE_DOCUMENT'),
|
||||
value: 'delete',
|
||||
@@ -86,17 +143,25 @@ const menuItems = computed(() => {
|
||||
return allOptions;
|
||||
});
|
||||
|
||||
const createdAt = computed(() => dynamicTime(props.createdAt));
|
||||
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||
|
||||
const displayLink = computed(() => formatDocumentLink(props.externalLink));
|
||||
const displayLink = computed(() =>
|
||||
isPdf.value
|
||||
? formatDocumentLink(props.externalLink)
|
||||
: getDocumentDisplayPath(props.externalLink)
|
||||
);
|
||||
const linkIcon = computed(() =>
|
||||
isPdfDocument(props.externalLink) ? 'i-ph-file-pdf' : 'i-ph-link-simple'
|
||||
isPdf.value ? '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>
|
||||
@@ -141,17 +206,41 @@ const handleAction = ({ action, value }) => {
|
||||
<span
|
||||
class="flex gap-1 items-center text-sm truncate shrink-0 text-n-slate-11"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
<Icon icon="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"
|
||||
>
|
||||
<i :class="linkIcon" class="shrink-0" />
|
||||
<Icon :icon="linkIcon" class="shrink-0" />
|
||||
<span class="truncate">{{ displayLink }}</span>
|
||||
</span>
|
||||
<div class="text-sm shrink-0 text-n-slate-11 line-clamp-1">
|
||||
{{ createdAt }}
|
||||
<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>
|
||||
</div>
|
||||
</CardLayout>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<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>
|
||||
@@ -0,0 +1,141 @@
|
||||
<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>
|
||||
@@ -0,0 +1,153 @@
|
||||
<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>
|
||||
+2
-1
@@ -15,7 +15,7 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(['close', 'createSuccess']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
@@ -26,6 +26,7 @@ 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,6 +1,7 @@
|
||||
<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';
|
||||
@@ -41,7 +42,8 @@ const starRatingValue = computed(() => {
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3" data-bubble-name="csat">
|
||||
<h4>{{ content || t('CONVERSATION.CSAT_REPLY_MESSAGE') }}</h4>
|
||||
<FormattedContent v-if="content" :content="content" />
|
||||
<h4 v-else>{{ 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,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -30,10 +31,16 @@ 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);
|
||||
@@ -42,14 +49,14 @@ const handleChange = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
<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"
|
||||
: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">
|
||||
@@ -58,6 +65,7 @@ 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}`"
|
||||
@@ -66,7 +74,7 @@ const handleChange = () => {
|
||||
:name="id"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
|
||||
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"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</div>
|
||||
@@ -75,5 +83,5 @@ const handleChange = () => {
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -803,6 +803,11 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.disabled),
|
||||
() => editorView?.setProps({})
|
||||
);
|
||||
|
||||
watch(
|
||||
computed(() => props.updateSelectionWith),
|
||||
(newValue, oldValue) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArticleMarkdownTransformer,
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -235,6 +236,7 @@ export default {
|
||||
const tr = editorView.state.tr.replaceSelectionWith(tableNode);
|
||||
editorView.dispatch(tr.scrollIntoView());
|
||||
},
|
||||
imageUpload: () => this.openFileBrowser(),
|
||||
};
|
||||
|
||||
const command = commandMap[actionKey];
|
||||
@@ -332,6 +334,9 @@ export default {
|
||||
createEditorView() {
|
||||
editorView = new EditorView(this.$refs.editor, {
|
||||
state: state,
|
||||
nodeViews: {
|
||||
image: imageResizeView,
|
||||
},
|
||||
dispatchTransaction: tx => {
|
||||
state = state.apply(tx);
|
||||
editorView.updateState(state);
|
||||
|
||||
@@ -60,6 +60,12 @@ 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,7 +5,6 @@ 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';
|
||||
@@ -144,8 +143,6 @@ export default {
|
||||
currentUser: 'getCurrentUser',
|
||||
lastEmail: 'getLastEmailInSelectedChat',
|
||||
globalConfig: 'globalConfig/get',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentContact() {
|
||||
const senderId = this.currentChat?.meta?.sender?.id;
|
||||
@@ -384,14 +381,8 @@ export default {
|
||||
const { slug = '' } = portal;
|
||||
return slug;
|
||||
},
|
||||
isQuotedEmailReplyEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
|
||||
);
|
||||
},
|
||||
quotedReplyPreference() {
|
||||
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
|
||||
if (!this.isAnEmailChannel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -416,11 +407,7 @@ export default {
|
||||
return truncatePreviewText(this.quotedEmailText, 80);
|
||||
},
|
||||
shouldShowQuotedReplyToggle() {
|
||||
return (
|
||||
this.isAnEmailChannel &&
|
||||
!this.isOnPrivateNote &&
|
||||
this.isQuotedEmailReplyEnabled
|
||||
);
|
||||
return this.isAnEmailChannel && !this.isOnPrivateNote;
|
||||
},
|
||||
shouldShowQuotedPreview() {
|
||||
return (
|
||||
@@ -577,7 +564,6 @@ export default {
|
||||
},
|
||||
shouldIncludeQuotedEmail() {
|
||||
return (
|
||||
this.isQuotedEmailReplyEnabled &&
|
||||
this.quotedReplyPreference &&
|
||||
this.shouldShowQuotedReplyToggle &&
|
||||
!!this.quotedEmailText
|
||||
|
||||
+1
-7
@@ -1,9 +1,8 @@
|
||||
<script setup>
|
||||
import { useTemplateRef, computed, ref, onMounted } from 'vue';
|
||||
import { useTemplateRef, computed, ref } 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';
|
||||
@@ -19,7 +18,6 @@ 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);
|
||||
@@ -77,10 +75,6 @@ const handleDismiss = () => {
|
||||
selectedTeam.value = null;
|
||||
toggleDropdown(false);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('teams/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -49,6 +50,7 @@ export default {
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
et,
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "ኩባንያ"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "Text",
|
||||
@@ -108,7 +109,8 @@
|
||||
"TABS": {
|
||||
"HEADER": "Custom Attributes",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "ኩባንያ"
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"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",
|
||||
@@ -181,6 +182,7 @@
|
||||
"BROWSER_LANGUAGE": "Browser Language",
|
||||
"MAIL_SUBJECT": "Email Subject",
|
||||
"COUNTRY_NAME": "Country",
|
||||
"COMPANY_NAME": "ኩባንያ",
|
||||
"REFERER_LINK": "Referrer Link",
|
||||
"ASSIGNEE_NAME": "Assignee",
|
||||
"TEAM_NAME": "Team",
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
"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",
|
||||
@@ -38,6 +43,8 @@
|
||||
"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,6 +140,7 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"UNREAD_COUNT_OVERFLOW": "9+"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY_AT": "Last activity",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
@@ -21,6 +22,121 @@
|
||||
"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,6 +386,7 @@
|
||||
"IDENTIFIER": "መለያ",
|
||||
"COUNTRY": "አገር",
|
||||
"CITY": "ከተማ",
|
||||
"COMPANY": "ኩባንያ",
|
||||
"CREATED_AT": "ተፈጥሯል በ",
|
||||
"LAST_ACTIVITY": "መጨረሻ እንቅስቃሴ",
|
||||
"REFERER_LINK": "የመግቢያ አገናኝ አገናኝ",
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
"LABELS_COUNT": "{count} labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
@@ -82,7 +83,9 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Join call"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "ተፈትኗል",
|
||||
@@ -92,6 +95,7 @@
|
||||
"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",
|
||||
@@ -362,7 +366,19 @@
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
"MACROS": "Macros",
|
||||
"LINEAR_ISSUES": "የተገናኙ የLinear ጉዳዮች",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
"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": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -525,6 +525,7 @@
|
||||
"PUBLISH": "ለህትመት አድርግ",
|
||||
"DRAFT": "እቅድ",
|
||||
"ARCHIVE": "አርክቭ",
|
||||
"TRANSLATE": "Translate",
|
||||
"DELETE": "ሰርዝ"
|
||||
},
|
||||
"STATUS": {
|
||||
@@ -579,6 +580,41 @@
|
||||
"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,7 +636,17 @@
|
||||
"WIDGET_BUILDER": "የዊጅት አሰራር መሣሪያ",
|
||||
"BOT_CONFIGURATION": "የቦት ቅንብሮች",
|
||||
"ACCOUNT_HEALTH": "የመለያ ጤና",
|
||||
"CSAT": "የደንበኞች ደህንነት ግምገማ (CSAT)"
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "የቻናል ቅድሚያዎች",
|
||||
"WIDGET_FEATURES": "የዊጅት ባህሪያት",
|
||||
@@ -745,6 +755,7 @@
|
||||
"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": "የኢንቦክስ ቅንብሮች",
|
||||
@@ -1000,7 +1011,8 @@
|
||||
"LABEL": "የይለፍ ቃል",
|
||||
"PLACE_HOLDER": "የይለፍ ቃል"
|
||||
},
|
||||
"ENABLE_SSL": "SSL አንቀሳቅስ"
|
||||
"ENABLE_SSL": "SSL አንቀሳቅስ",
|
||||
"AUTH_MECHANISM": "ማረጋገጫ"
|
||||
},
|
||||
"MICROSOFT": {
|
||||
"TITLE": "Microsoft",
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -26,6 +27,8 @@ 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';
|
||||
@@ -47,6 +50,7 @@ export default {
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
...companies,
|
||||
...components,
|
||||
...contact,
|
||||
...contactFilters,
|
||||
@@ -65,6 +69,8 @@ export default {
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
...macros,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...report,
|
||||
...resetPassword,
|
||||
...search,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"PLACEHOLDER": "Select Inbox"
|
||||
},
|
||||
"SUBMIT": "Create",
|
||||
"VALIDATING_OPENAI": "Validating with OpenAI...",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -742,6 +742,7 @@
|
||||
"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.",
|
||||
@@ -749,6 +750,51 @@
|
||||
"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 ጥያቄዎች ቀጥተኛ ከሰነዱ ተፈጥረዋል።"
|
||||
@@ -792,11 +838,15 @@
|
||||
},
|
||||
"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,6 +49,9 @@
|
||||
"ERROR_MESSAGE": "There was an error deleting the macro. Please try again later"
|
||||
}
|
||||
},
|
||||
"VIEW": {
|
||||
"TOOLTIP": "View macro"
|
||||
},
|
||||
"EDIT": {
|
||||
"TOOLTIP": "Edit macro",
|
||||
"API": {
|
||||
@@ -66,7 +69,9 @@
|
||||
"LABEL": "Macro Visibility",
|
||||
"GLOBAL": {
|
||||
"LABEL": "Public",
|
||||
"DESCRIPTION": "This macro is available publicly for all agents in this account."
|
||||
"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."
|
||||
},
|
||||
"PERSONAL": {
|
||||
"LABEL": "Private",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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,7 +10,8 @@
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "المحادثات",
|
||||
"CONTACT": "جهات الاتصال"
|
||||
"CONTACT": "جهات الاتصال",
|
||||
"COMPANY": "المنشأة"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "النص",
|
||||
@@ -108,7 +109,8 @@
|
||||
"TABS": {
|
||||
"HEADER": "صفات مخصصة",
|
||||
"CONVERSATION": "المحادثات",
|
||||
"CONTACT": "جهات الاتصال"
|
||||
"CONTACT": "جهات الاتصال",
|
||||
"COMPANY": "المنشأة"
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"ATLEAST_ONE_ACTION_REQUIRED": "إجراء واحد على الأقل مطلوب"
|
||||
},
|
||||
"NONE_OPTION": "لا شيء",
|
||||
"LAST_RESPONDING_AGENT": "آخر وكيل قام بالرد",
|
||||
"EVENTS": {
|
||||
"CONVERSATION_CREATED": "تم إنشاء المحادثة",
|
||||
"CONVERSATION_UPDATED": "تم تحديث المحادثة",
|
||||
@@ -181,6 +182,7 @@
|
||||
"BROWSER_LANGUAGE": "لغة المتصفح",
|
||||
"MAIL_SUBJECT": "Email Subject",
|
||||
"COUNTRY_NAME": "الدولة",
|
||||
"COMPANY_NAME": "المنشأة",
|
||||
"REFERER_LINK": "Referrer Link",
|
||||
"ASSIGNEE_NAME": "المكلَّف",
|
||||
"TEAM_NAME": "الفريق",
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
"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": "تعيين فريق",
|
||||
@@ -38,6 +43,8 @@
|
||||
"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,6 +140,7 @@
|
||||
"HIDE_QUOTED_TEXT": "إخفاء النص المقتبس",
|
||||
"SHOW_QUOTED_TEXT": "إظهار النص المقتبس",
|
||||
"MESSAGE_READ": "قراءة",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"UNREAD_COUNT_OVERFLOW": "9+"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"NAME": "الاسم",
|
||||
"DOMAIN": "النطاق",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"LAST_ACTIVITY_AT": "آخر نشاط",
|
||||
"CONTACTS_COUNT": "عدد جهات الاتصال"
|
||||
}
|
||||
},
|
||||
@@ -21,6 +22,121 @@
|
||||
"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,6 +386,7 @@
|
||||
"IDENTIFIER": "المعرف",
|
||||
"COUNTRY": "الدولة",
|
||||
"CITY": "المدينة",
|
||||
"COMPANY": "المنشأة",
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"LAST_ACTIVITY": "آخر نشاط",
|
||||
"REFERER_LINK": "رابط المرجع",
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "الرسالة غير متوفرة",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "إظهار السمات",
|
||||
"HIDE_LABELS": "إخفاء السمات"
|
||||
"HIDE_LABELS": "إخفاء السمات",
|
||||
"LABELS_COUNT": "{count} علامة"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
@@ -82,7 +83,9 @@
|
||||
"CALL_ENDED": "Call ended",
|
||||
"NOT_ANSWERED_YET": "Not answered yet",
|
||||
"THEY_ANSWERED": "They answered",
|
||||
"YOU_ANSWERED": "You answered"
|
||||
"YOU_ANSWERED": "You answered",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "انضم إلى المكالمة"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "حل المحادثة",
|
||||
@@ -92,6 +95,7 @@
|
||||
"OPEN": "المزيد",
|
||||
"CLOSE": "أغلق",
|
||||
"DETAILS": "التفاصيل",
|
||||
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
|
||||
"SNOOZED_UNTIL": "غفوة حتى",
|
||||
"SNOOZED_UNTIL_TOMORROW": "تأجيل حتى الغد",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "تأجيل حتى الأسبوع القادم",
|
||||
@@ -362,7 +366,19 @@
|
||||
"PREVIOUS_CONVERSATION": "المحادثات السابقة",
|
||||
"MACROS": "ماكروس",
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
"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": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
|
||||
@@ -525,6 +525,7 @@
|
||||
"PUBLISH": "نشر",
|
||||
"DRAFT": "مسودة",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "ترجم",
|
||||
"DELETE": "حذف"
|
||||
},
|
||||
"STATUS": {
|
||||
@@ -579,6 +580,41 @@
|
||||
"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,7 +636,17 @@
|
||||
"WIDGET_BUILDER": "منشئ اللايف شات",
|
||||
"BOT_CONFIGURATION": "اعدادات البوت",
|
||||
"ACCOUNT_HEALTH": "Account Health",
|
||||
"CSAT": "تقييم رضاء العملاء"
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Channel Preferences",
|
||||
"WIDGET_FEATURES": "Widget features",
|
||||
@@ -745,6 +755,7 @@
|
||||
"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": "إعدادات قناة التواصل",
|
||||
@@ -1000,7 +1011,8 @@
|
||||
"LABEL": "كلمة المرور",
|
||||
"PLACE_HOLDER": "كلمة المرور"
|
||||
},
|
||||
"ENABLE_SSL": "تمكين SSL"
|
||||
"ENABLE_SSL": "تمكين SSL",
|
||||
"AUTH_MECHANISM": "المصادقة"
|
||||
},
|
||||
"MICROSOFT": {
|
||||
"TITLE": "Microsoft",
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -26,6 +27,8 @@ 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';
|
||||
@@ -47,6 +50,7 @@ export default {
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
...companies,
|
||||
...components,
|
||||
...contact,
|
||||
...contactFilters,
|
||||
@@ -65,6 +69,8 @@ export default {
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
...macros,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...report,
|
||||
...resetPassword,
|
||||
...search,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"PLACEHOLDER": "اختر صندوق الوارد"
|
||||
},
|
||||
"SUBMIT": "إنشاء",
|
||||
"VALIDATING_OPENAI": "Validating with OpenAI...",
|
||||
"CANCEL": "إلغاء"
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -742,6 +742,7 @@
|
||||
"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.",
|
||||
@@ -749,6 +750,51 @@
|
||||
"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."
|
||||
@@ -792,11 +838,15 @@
|
||||
},
|
||||
"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,6 +49,9 @@
|
||||
"ERROR_MESSAGE": "حدث خطأ أثناء حذف الماكرو. الرجاء المحاولة مرة أخرى في وقت لاحق"
|
||||
}
|
||||
},
|
||||
"VIEW": {
|
||||
"TOOLTIP": "View macro"
|
||||
},
|
||||
"EDIT": {
|
||||
"TOOLTIP": "تعديل الماكرو",
|
||||
"API": {
|
||||
@@ -66,7 +69,9 @@
|
||||
"LABEL": "الرؤية الخاصة بالماكرو",
|
||||
"GLOBAL": {
|
||||
"LABEL": "عامة",
|
||||
"DESCRIPTION": "هذا الماكرو متاح بشكل عام لجميع الوكلاء في هذا الحساب."
|
||||
"DESCRIPTION": "هذا الماكرو متاح بشكل عام لجميع الوكلاء في هذا الحساب.",
|
||||
"CREATE_DISABLED_DESCRIPTION": "Only administrators can create public macros.",
|
||||
"EDIT_DISABLED_DESCRIPTION": "Only administrators can edit public macros."
|
||||
},
|
||||
"PERSONAL": {
|
||||
"LABEL": "خاص",
|
||||
|
||||
@@ -55,6 +55,10 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"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,7 +10,8 @@
|
||||
"NO_RESULTS": "No attributes found matching your search",
|
||||
"ATTRIBUTE_MODELS": {
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "Şirkət"
|
||||
},
|
||||
"ATTRIBUTE_TYPES": {
|
||||
"TEXT": "Text",
|
||||
@@ -108,7 +109,8 @@
|
||||
"TABS": {
|
||||
"HEADER": "Custom Attributes",
|
||||
"CONVERSATION": "Conversation",
|
||||
"CONTACT": "Contact"
|
||||
"CONTACT": "Contact",
|
||||
"COMPANY": "Şirkət"
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": {
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
"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",
|
||||
@@ -181,6 +182,7 @@
|
||||
"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,7 +6,12 @@
|
||||
"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",
|
||||
@@ -38,6 +43,8 @@
|
||||
"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,6 +140,7 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"UNREAD_COUNT_OVERFLOW": "9+"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY_AT": "Last activity",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
@@ -21,6 +22,121 @@
|
||||
"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": "Xüsusiyyətlər",
|
||||
"CONTACTS": "Əlaqələr",
|
||||
"HISTORY": "Tarix",
|
||||
"NOTES": "Qeydlər"
|
||||
}
|
||||
},
|
||||
"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": "Əlaqə əlavə et",
|
||||
"REMOVE": "Remove contact"
|
||||
},
|
||||
"DIALOGS": {
|
||||
"ADD": {
|
||||
"DESCRIPTION": "Search for an existing contact and link it to this company.",
|
||||
"SEARCH_PLACEHOLDER": "Əlaqələrdə axtarış...",
|
||||
"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": "Şirkət",
|
||||
"CONTACT_LABEL": "Əlaqə",
|
||||
"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": "Yaradılıb {date}",
|
||||
"LAST_ACTIVE": "Son fəaliyyət {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": "Domen"
|
||||
}
|
||||
},
|
||||
"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,6 +386,7 @@
|
||||
"IDENTIFIER": "Identifier",
|
||||
"COUNTRY": "Ölkə",
|
||||
"CITY": "Şəhər",
|
||||
"COMPANY": "Şirkət",
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Son fəaliyyət",
|
||||
"REFERER_LINK": "Referer link",
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Mesaj mövcud deyil",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Etiketləri göstər",
|
||||
"HIDE_LABELS": "Etiketləri gizlədin"
|
||||
"HIDE_LABELS": "Etiketləri gizlədin",
|
||||
"LABELS_COUNT": "{count} labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Gələn zəng",
|
||||
@@ -82,7 +83,9 @@
|
||||
"CALL_ENDED": "Zəng bitdi",
|
||||
"NOT_ANSWERED_YET": "Hələ cavab verilməyib",
|
||||
"THEY_ANSWERED": "Onlar cavab verdi",
|
||||
"YOU_ANSWERED": "Siz cavab verdiniz"
|
||||
"YOU_ANSWERED": "Siz cavab verdiniz",
|
||||
"AGENT_ANSWERED": "{agentName} answered",
|
||||
"JOIN_CALL": "Zəngə qoşul"
|
||||
},
|
||||
"HEADER": {
|
||||
"RESOLVE_ACTION": "Həll et",
|
||||
@@ -92,6 +95,7 @@
|
||||
"OPEN": "Daha çox",
|
||||
"CLOSE": "Bağla",
|
||||
"DETAILS": "təfərrüatlar",
|
||||
"COPY_ID_SUCCESS": "Conversation ID copied to clipboard",
|
||||
"SNOOZED_UNTIL": "Gecikdirilib",
|
||||
"SNOOZED_UNTIL_TOMORROW": "Sabaha qədər təxirə salındı",
|
||||
"SNOOZED_UNTIL_NEXT_WEEK": "Gələn həftəyə qədər təxirə salındı",
|
||||
@@ -362,7 +366,19 @@
|
||||
"PREVIOUS_CONVERSATION": "Əvvəlki Söhbətlər",
|
||||
"MACROS": "Makrolar",
|
||||
"LINEAR_ISSUES": "Əlaqəli Linear məsələlər",
|
||||
"SHOPIFY_ORDERS": "Shopify Sifarişləri"
|
||||
"SHOPIFY_ORDERS": "Shopify Sifarişləri",
|
||||
"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": {
|
||||
"ORDER_ID": "Sifariş #{id}",
|
||||
|
||||
@@ -525,6 +525,7 @@
|
||||
"PUBLISH": "Publish",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Tərcümə et",
|
||||
"DELETE": "Delete"
|
||||
},
|
||||
"STATUS": {
|
||||
@@ -579,6 +580,41 @@
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
}
|
||||
},
|
||||
"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": "Tərcümə et",
|
||||
"SELECT_ALL": "Hamısını seç ({count})",
|
||||
"SELECTED_COUNT": "{count} seçildi",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"TRANSLATE_BUTTON": "Tərcümə et",
|
||||
"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": "Yayımla",
|
||||
"DRAFT": "Qaralama",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Tərcümə et",
|
||||
"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,7 +636,17 @@
|
||||
"WIDGET_BUILDER": "Widget Qurucusu",
|
||||
"BOT_CONFIGURATION": "Bot Konfiqurasiyası",
|
||||
"ACCOUNT_HEALTH": "Hesabın sağlamlığı",
|
||||
"CSAT": "CSAT"
|
||||
"CSAT": "CSAT",
|
||||
"VOICE": "Səs"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"CHANNEL_PREFERENCES": "Kanal üstünlükləri",
|
||||
"WIDGET_FEATURES": "Widget xüsusiyyətləri",
|
||||
@@ -745,6 +755,7 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "E-poçtda Agentin adının göstərilməsini aktivləşdirin/deaktivləşdirin, deaktiv edilsə biznes adı göstəriləcək",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "E-poçt vasitəsilə söhbət davamlılığını aktiv edin",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Əlaqə e-poçt ünvanı mövcuddursa, söhbətlər e-poçt vasitəsilə davam edəcək.",
|
||||
"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": "Söhbət yönləndirilməsi",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Mövcud əlaqələr üçün söhbət yaradılmasını qurun",
|
||||
"INBOX_UPDATE_TITLE": "Gələn Qutu Parametrləri",
|
||||
@@ -1000,7 +1011,8 @@
|
||||
"LABEL": "Şifrə",
|
||||
"PLACE_HOLDER": "Şifrə"
|
||||
},
|
||||
"ENABLE_SSL": "SSL-i aktiv et"
|
||||
"ENABLE_SSL": "SSL-i aktiv et",
|
||||
"AUTH_MECHANISM": "Avtorizasiya"
|
||||
},
|
||||
"MICROSOFT": {
|
||||
"TITLE": "Microsoft",
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
@@ -26,6 +27,8 @@ 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';
|
||||
@@ -47,6 +50,7 @@ export default {
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
...companies,
|
||||
...components,
|
||||
...contact,
|
||||
...contactFilters,
|
||||
@@ -65,6 +69,8 @@ export default {
|
||||
...labelsMgmt,
|
||||
...login,
|
||||
...macros,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
...report,
|
||||
...resetPassword,
|
||||
...search,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"PLACEHOLDER": "Select Inbox"
|
||||
},
|
||||
"SUBMIT": "Create",
|
||||
"VALIDATING_OPENAI": "Validating with OpenAI...",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
|
||||
@@ -742,6 +742,7 @@
|
||||
"SELECT_ALL": "Hamısını seç ({count})",
|
||||
"UNSELECT_ALL": "Hamısını seçmə ({count})",
|
||||
"BULK_DELETE_BUTTON": "Sil",
|
||||
"BULK_SYNC_BUTTON": "Refresh",
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Sənədlər silinsin?",
|
||||
"DESCRIPTION": "Seçilmiş sənədləri silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
|
||||
@@ -749,6 +750,51 @@
|
||||
"SUCCESS_MESSAGE": "Sənədlər uğurla silindi",
|
||||
"ERROR_MESSAGE": "Sənədlər silinərkən xəta baş verdi, yenidən cəhd edin."
|
||||
},
|
||||
"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": "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": "Əlaqəli FAQ-lar",
|
||||
"DESCRIPTION": "Bu FAQ-lar birbaşa sənəddən yaradılıb."
|
||||
@@ -792,11 +838,15 @@
|
||||
},
|
||||
"OPTIONS": {
|
||||
"VIEW_RELATED_RESPONSES": "Əlaqəli cavablara bax",
|
||||
"SYNC_NOW": "Refresh now",
|
||||
"RETRY_SYNC": "Retry refresh",
|
||||
"DELETE_DOCUMENT": "Sənədi sil"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "Sənəd yoxdur",
|
||||
"SUBTITLE": "Sənədlər assistentiniz tərəfindən FAQ-lar yaratmaq üçün istifadə olunur. Assistentinizə kontekst vermək üçün sənədləri əlavə edə bilərsiniz.",
|
||||
"FILTERED_TITLE": "No matching documents",
|
||||
"FILTERED_SUBTITLE": "Try changing the source, status, or search term.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Captain Sənəd",
|
||||
"NOTE": "Captain-da sənəd assistent üçün bilik mənbəyi rolunu oynayır. Kömək mərkəzinizi və ya təlimatları bağlayaraq, Captain məzmunu analiz edə və müştəri sorğuları üçün dəqiq cavablar verə bilər."
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user