Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fe2be5e1a | ||
|
|
2edd7c569f | ||
|
|
13ed6c1336 | ||
|
|
72326788fb | ||
|
|
66fbe1bde7 | ||
|
|
a543cc92df | ||
|
|
22331fb626 | ||
|
|
08f0ea2c98 | ||
|
|
8fefdea4e8 | ||
|
|
a513f152ed | ||
|
|
a0cf25ccde | ||
|
|
8d85a02ca9 | ||
|
|
7e1458fd32 | ||
|
|
d017156f32 | ||
|
|
c1f6d9f76f | ||
|
|
ed562832a6 | ||
|
|
12d7be62d3 | ||
|
|
6040e50265 | ||
|
|
488c334b72 | ||
|
|
7c2ca1a07e | ||
|
|
bca8c99148 | ||
|
|
ecfa6bf6a2 | ||
|
|
616bbced9c | ||
|
|
24f49b9b5a | ||
|
|
ef24eef0b9 | ||
|
|
e90c231dd3 | ||
|
|
100d39d58a | ||
|
|
f57c9279aa | ||
|
|
6eecd84b22 | ||
|
|
a8febc00d3 | ||
|
|
80c87da8c9 | ||
|
|
0fbb9b91b2 | ||
|
|
b52b2b9a47 | ||
|
|
12134f9391 | ||
|
|
30996140a3 | ||
|
|
77a0f18685 | ||
|
|
2556de1f38 | ||
|
|
a9de672932 | ||
|
|
0ccbe5882c |
@@ -4,6 +4,8 @@ module.exports = {
|
|||||||
'prettier',
|
'prettier',
|
||||||
'plugin:vue/vue3-recommended',
|
'plugin:vue/vue3-recommended',
|
||||||
'plugin:vitest-globals/recommended',
|
'plugin:vitest-globals/recommended',
|
||||||
|
// use recommended-legacy when upgrading the plugin to v4
|
||||||
|
'plugin:@intlify/vue-i18n/recommended',
|
||||||
],
|
],
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
@@ -229,6 +231,18 @@ module.exports = {
|
|||||||
'vue/singleline-html-element-content-newline': 'off',
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
'import/extensions': ['off'],
|
'import/extensions': ['off'],
|
||||||
'no-console': 'error',
|
'no-console': 'error',
|
||||||
|
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
|
||||||
|
'@intlify/vue-i18n/no-unused-keys': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
extensions: ['.js', '.vue'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'vue-i18n': {
|
||||||
|
localeDir: './app/javascript/*/i18n/**.json',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
browser: true,
|
browser: true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
nightly:
|
nightly:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- name: get installer
|
- name: get installer
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ test/cypress/videos/*
|
|||||||
|
|
||||||
#ignore files under .vscode directory
|
#ignore files under .vscode directory
|
||||||
.vscode
|
.vscode
|
||||||
|
.cursor
|
||||||
|
|
||||||
# yalc for local testing
|
# yalc for local testing
|
||||||
.yalc
|
.yalc
|
||||||
|
|||||||
+2
-2
@@ -561,7 +561,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (2.2.11)
|
rack (2.2.12)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@@ -799,7 +799,7 @@ GEM
|
|||||||
unf_ext (0.0.8.2)
|
unf_ext (0.0.8.2)
|
||||||
unicode-display_width (2.4.2)
|
unicode-display_width (2.4.2)
|
||||||
uniform_notifier (1.16.0)
|
uniform_notifier (1.16.0)
|
||||||
uri (0.13.0)
|
uri (1.0.3)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
button,
|
button:not(.reset-base),
|
||||||
input[type="button"],
|
input[type='button']:not(.reset-base),
|
||||||
input[type="reset"],
|
input[type='reset']:not(.reset-base),
|
||||||
input[type="submit"],
|
input[type='submit']:not(.reset-base),
|
||||||
.button {
|
.button:not(.reset-base) {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-color: $color-woot;
|
background-color: $color-woot;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-container {
|
.value-container {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
|
||||||
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
before_action :inbox, :contact, :contact_inbox, only: [:create]
|
||||||
|
|
||||||
|
ATTACHMENT_RESULTS_PER_PAGE = 100
|
||||||
|
|
||||||
def index
|
def index
|
||||||
result = conversation_finder.perform
|
result = conversation_finder.perform
|
||||||
@conversations = result[:conversations]
|
@conversations = result[:conversations]
|
||||||
@@ -24,7 +26,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def attachments
|
def attachments
|
||||||
|
@attachments_count = @conversation.attachments.count
|
||||||
@attachments = @conversation.attachments
|
@attachments = @conversation.attachments
|
||||||
|
.includes(:message)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
.page(attachment_params[:page])
|
||||||
|
.per(ATTACHMENT_RESULTS_PER_PAGE)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
@@ -124,6 +131,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
params.permit(:priority)
|
params.permit(:priority)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def attachment_params
|
||||||
|
params.permit(:page)
|
||||||
|
end
|
||||||
|
|
||||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
||||||
# rubocop:disable Rails/SkipsModelValidations
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
|
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
|
||||||
|
before_action :fetch_hook, only: [:destroy]
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@hook.destroy!
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
def teams
|
def teams
|
||||||
teams = linear_processor_service.teams
|
teams = linear_processor_service.teams
|
||||||
@@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
|||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def fetch_hook
|
||||||
|
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
|
||||||
|
before_action :set_group_scope, only: [:grouped_conversation_metrics]
|
||||||
|
|
||||||
|
before_action :check_authorization
|
||||||
|
|
||||||
|
def conversation_metrics
|
||||||
|
render json: {
|
||||||
|
open: @conversations.open.count,
|
||||||
|
unattended: @conversations.open.unattended.count,
|
||||||
|
unassigned: @conversations.open.unassigned.count,
|
||||||
|
pending: @conversations.pending.count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def grouped_conversation_metrics
|
||||||
|
count_by_group = @conversations.open.group(@group_scope).count
|
||||||
|
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
|
||||||
|
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
|
||||||
|
|
||||||
|
group_metrics = count_by_group.map do |group_id, count|
|
||||||
|
metric = {
|
||||||
|
open: count,
|
||||||
|
unattended: unattended_by_group[group_id] || 0,
|
||||||
|
unassigned: unassigned_by_group[group_id] || 0
|
||||||
|
}
|
||||||
|
metric[@group_scope] = group_id
|
||||||
|
metric
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: group_metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_authorization
|
||||||
|
authorize :report, :view?
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_group_scope
|
||||||
|
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
|
||||||
|
team_id
|
||||||
|
assignee_id
|
||||||
|
].include?(permitted_params[:group_by])
|
||||||
|
|
||||||
|
@group_scope = permitted_params[:group_by]
|
||||||
|
end
|
||||||
|
|
||||||
|
def team
|
||||||
|
return unless permitted_params[:team_id]
|
||||||
|
|
||||||
|
@team ||= Current.account.teams.find(permitted_params[:team_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_conversations
|
||||||
|
scope = Current.account.conversations
|
||||||
|
scope = scope.where(team_id: team.id) if team.present?
|
||||||
|
@conversations = scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:team_id, :group_by)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,10 +5,11 @@ module SwitchLocale
|
|||||||
|
|
||||||
def switch_locale(&)
|
def switch_locale(&)
|
||||||
# priority is for locale set in query string (mostly for widget/from js sdk)
|
# priority is for locale set in query string (mostly for widget/from js sdk)
|
||||||
locale ||= locale_from_params
|
locale ||= params[:locale]
|
||||||
|
|
||||||
locale ||= locale_from_custom_domain
|
locale ||= locale_from_custom_domain
|
||||||
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
# if locale is not set in account, let's use DEFAULT_LOCALE env variable
|
||||||
locale ||= locale_from_env_variable
|
locale ||= ENV.fetch('DEFAULT_LOCALE', nil)
|
||||||
set_locale(locale, &)
|
set_locale(locale, &)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,26 +33,30 @@ module SwitchLocale
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_locale(locale, &)
|
def set_locale(locale, &)
|
||||||
# if locale is empty, use default_locale
|
safe_locale = validate_and_get_locale(locale)
|
||||||
locale ||= I18n.default_locale
|
|
||||||
# Ensure locale won't bleed into other requests
|
# Ensure locale won't bleed into other requests
|
||||||
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
|
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
|
||||||
I18n.with_locale(locale, &)
|
I18n.with_locale(safe_locale, &)
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale_from_params
|
def validate_and_get_locale(locale)
|
||||||
I18n.available_locales.map(&:to_s).include?(params[:locale]) ? params[:locale] : nil
|
return I18n.default_locale.to_s if locale.blank?
|
||||||
|
|
||||||
|
available_locales = I18n.available_locales.map(&:to_s)
|
||||||
|
locale_without_variant = locale.split('_')[0]
|
||||||
|
|
||||||
|
if available_locales.include?(locale)
|
||||||
|
locale
|
||||||
|
elsif available_locales.include?(locale_without_variant)
|
||||||
|
locale_without_variant
|
||||||
|
else
|
||||||
|
I18n.default_locale.to_s
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def locale_from_account(account)
|
def locale_from_account(account)
|
||||||
return unless account
|
return unless account
|
||||||
|
|
||||||
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
|
account.locale
|
||||||
end
|
|
||||||
|
|
||||||
def locale_from_env_variable
|
|
||||||
return unless ENV.fetch('DEFAULT_LOCALE', nil)
|
|
||||||
|
|
||||||
I18n.available_locales.map(&:to_s).include?(ENV.fetch('DEFAULT_LOCALE')) ? ENV.fetch('DEFAULT_LOCALE') : nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
class Linear::CallbacksController < ApplicationController
|
||||||
|
include Linear::IntegrationHelper
|
||||||
|
|
||||||
|
def show
|
||||||
|
@response = oauth_client.auth_code.get_token(
|
||||||
|
params[:code],
|
||||||
|
redirect_uri: "#{base_url}/linear/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
handle_response
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Linear callback error: #{e.message}")
|
||||||
|
redirect_to linear_redirect_uri
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def oauth_client
|
||||||
|
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
|
||||||
|
app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
|
||||||
|
|
||||||
|
OAuth2::Client.new(
|
||||||
|
app_id,
|
||||||
|
app_secret,
|
||||||
|
{
|
||||||
|
site: 'https://api.linear.app',
|
||||||
|
token_url: '/oauth/token',
|
||||||
|
authorize_url: '/oauth/authorize'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_response
|
||||||
|
hook = account.hooks.new(
|
||||||
|
access_token: parsed_body['access_token'],
|
||||||
|
status: 'enabled',
|
||||||
|
app_id: 'linear',
|
||||||
|
settings: {
|
||||||
|
token_type: parsed_body['token_type'],
|
||||||
|
expires_in: parsed_body['expires_in'],
|
||||||
|
scope: parsed_body['scope']
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
|
||||||
|
hook.save!
|
||||||
|
redirect_to linear_redirect_uri
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Linear callback error: #{e.message}")
|
||||||
|
redirect_to linear_redirect_uri
|
||||||
|
end
|
||||||
|
|
||||||
|
def account
|
||||||
|
@account ||= Account.find(account_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_id
|
||||||
|
return unless params[:state]
|
||||||
|
|
||||||
|
verify_linear_token(params[:state])
|
||||||
|
end
|
||||||
|
|
||||||
|
def linear_redirect_uri
|
||||||
|
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
|
||||||
|
end
|
||||||
|
|
||||||
|
def parsed_body
|
||||||
|
@parsed_body ||= @response.response.parsed
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_url
|
||||||
|
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class Public::Api::V1::Portals::BaseController < PublicController
|
class Public::Api::V1::Portals::BaseController < PublicController
|
||||||
|
include SwitchLocale
|
||||||
|
|
||||||
before_action :show_plain_layout
|
before_action :show_plain_layout
|
||||||
before_action :set_color_scheme
|
before_action :set_color_scheme
|
||||||
before_action :set_global_config
|
before_action :set_global_config
|
||||||
@@ -27,14 +29,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def switch_locale_with_portal(&)
|
def switch_locale_with_portal(&)
|
||||||
locale_without_variant = params[:locale].split('_')[0]
|
@locale = validate_and_get_locale(params[:locale])
|
||||||
is_locale_available = I18n.available_locales.map(&:to_s).include?(params[:locale])
|
|
||||||
is_locale_variant_available = I18n.available_locales.map(&:to_s).include?(locale_without_variant)
|
|
||||||
if is_locale_available
|
|
||||||
@locale = params[:locale]
|
|
||||||
elsif is_locale_variant_available
|
|
||||||
@locale = locale_without_variant
|
|
||||||
end
|
|
||||||
|
|
||||||
I18n.with_locale(@locale, &)
|
I18n.with_locale(@locale, &)
|
||||||
end
|
end
|
||||||
@@ -44,12 +39,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
|
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
|
||||||
render_404 && return if article.blank?
|
render_404 && return if article.blank?
|
||||||
|
|
||||||
@locale = if article.category.present?
|
article_locale = if article.category.present?
|
||||||
article.category.locale
|
article.category.locale
|
||||||
else
|
else
|
||||||
article.portal.default_locale
|
article.portal.default_locale
|
||||||
end
|
end
|
||||||
|
@locale = validate_and_get_locale(article_locale)
|
||||||
I18n.with_locale(@locale, &)
|
I18n.with_locale(@locale, &)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
|||||||
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
||||||
when 'email'
|
when 'email'
|
||||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||||
|
when 'linear'
|
||||||
|
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
|
||||||
else
|
else
|
||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
# COLLECTION_FILTERS = {
|
# COLLECTION_FILTERS = {
|
||||||
# open: ->(resources) { resources.where(open: true) }
|
# open: ->(resources) { resources.where(open: true) }
|
||||||
# }.freeze
|
# }.freeze
|
||||||
COLLECTION_FILTERS = {}.freeze
|
COLLECTION_FILTERS = {
|
||||||
|
active: ->(resources) { resources.where(status: :active) },
|
||||||
|
suspended: ->(resources) { resources.where(status: :suspended) },
|
||||||
|
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
# Overwrite this method to customize how accounts are displayed
|
# Overwrite this method to customize how accounts are displayed
|
||||||
# across all pages of the admin dashboard.
|
# across all pages of the admin dashboard.
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard
|
|||||||
# COLLECTION_FILTERS = {
|
# COLLECTION_FILTERS = {
|
||||||
# open: ->(resources) { resources.where(open: true) }
|
# open: ->(resources) { resources.where(open: true) }
|
||||||
# }.freeze
|
# }.freeze
|
||||||
COLLECTION_FILTERS = {}.freeze
|
COLLECTION_FILTERS = {
|
||||||
|
super_admin: ->(resources) { resources.where(type: 'SuperAdmin') },
|
||||||
|
confirmed: ->(resources) { resources.where.not(confirmed_at: nil) },
|
||||||
|
unconfirmed: ->(resources) { resources.where(confirmed_at: nil) },
|
||||||
|
recent: ->(resources) { resources.where('created_at > ?', 30.days.ago) }
|
||||||
|
}.freeze
|
||||||
|
|
||||||
# Overwrite this method to customize how users are displayed
|
# Overwrite this method to customize how users are displayed
|
||||||
# across all pages of the admin dashboard.
|
# across all pages of the admin dashboard.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module FilterHelper
|
module Filters::FilterHelper
|
||||||
def build_condition_query(model_filters, query_hash, current_index)
|
def build_condition_query(model_filters, query_hash, current_index)
|
||||||
current_filter = model_filters[query_hash['attribute_key']]
|
current_filter = model_filters[query_hash['attribute_key']]
|
||||||
|
|
||||||
@@ -89,4 +89,18 @@ module FilterHelper
|
|||||||
operator = condition['query_operator'].upcase
|
operator = condition['query_operator'].upcase
|
||||||
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def conversation_status_values(values)
|
||||||
|
return Conversation.statuses.values if values.include?('all')
|
||||||
|
|
||||||
|
values.map { |x| Conversation.statuses[x.to_sym] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_priority_values(values)
|
||||||
|
values.map { |x| Conversation.priorities[x.to_sym] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_type_values(values)
|
||||||
|
values.map { |x| Message.message_types[x.to_sym] }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Linear::IntegrationHelper
|
||||||
|
# Generates a signed JWT token for Linear integration
|
||||||
|
#
|
||||||
|
# @param account_id [Integer] The account ID to encode in the token
|
||||||
|
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||||
|
def generate_linear_token(account_id)
|
||||||
|
return if client_secret.blank?
|
||||||
|
|
||||||
|
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Failed to generate Linear token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def token_payload(account_id)
|
||||||
|
{
|
||||||
|
sub: account_id,
|
||||||
|
iat: Time.current.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Verifies and decodes a Linear JWT token
|
||||||
|
#
|
||||||
|
# @param token [String] The JWT token to verify
|
||||||
|
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||||
|
def verify_linear_token(token)
|
||||||
|
return if token.blank? || client_secret.blank?
|
||||||
|
|
||||||
|
decode_token(token, client_secret)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def client_secret
|
||||||
|
@client_secret ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode_token(token, secret)
|
||||||
|
JWT.decode(token, secret, true, {
|
||||||
|
algorithm: 'HS256',
|
||||||
|
verify_expiration: true
|
||||||
|
}).first['sub']
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("Unexpected error verifying Linear token: #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -14,6 +14,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue';
|
|||||||
import { setColorTheme } from './helper/themeHelper';
|
import { setColorTheme } from './helper/themeHelper';
|
||||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||||
import {
|
import {
|
||||||
registerSubscription,
|
registerSubscription,
|
||||||
verifyServiceWorkerExistence,
|
verifyServiceWorkerExistence,
|
||||||
@@ -37,8 +38,15 @@ export default {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { accountId } = useAccount();
|
const { accountId } = useAccount();
|
||||||
|
// Use the font size composable (it automatically sets up the watcher)
|
||||||
|
const { currentFontSize } = useFontSize();
|
||||||
|
|
||||||
return { router, store, currentAccountId: accountId };
|
return {
|
||||||
|
router,
|
||||||
|
store,
|
||||||
|
currentAccountId: accountId,
|
||||||
|
currentFontSize,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainBulkActionsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/bulk_actions', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainBulkActionsAPI();
|
||||||
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
|
|||||||
requestCopilot(conversationId, body) {
|
requestCopilot(conversationId, body) {
|
||||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getInboxAssistant(conversationId) {
|
||||||
|
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new ConversationApi();
|
export default new ConversationApi();
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type='file'] {
|
input[type='file'] {
|
||||||
@apply bg-white dark:bg-slate-800 leading-[1.15] mb-4;
|
@apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select
|
// Select
|
||||||
@@ -141,11 +141,16 @@ code {
|
|||||||
@apply text-xs border-0;
|
@apply text-xs border-0;
|
||||||
|
|
||||||
&.hljs {
|
&.hljs {
|
||||||
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5;
|
@apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
|
||||||
|
|
||||||
.hljs-number,
|
.hljs-number,
|
||||||
.hljs-string {
|
.hljs-string {
|
||||||
@apply text-red-800 dark:text-red-400;
|
@apply text-red-800 dark:text-red-400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-tag {
|
||||||
|
@apply text-n-slate-11;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||||
<div class="w-full max-w-[960px] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||||
<span class="text-xl font-medium text-n-slate-12">
|
<span class="text-xl font-medium text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
<div class="w-full max-w-[60rem] mx-auto py-4">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ const handleSubmit = campaignDetails => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||||
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
|
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
|
||||||
|
|||||||
+1
-1
@@ -39,7 +39,7 @@ const handleClose = () => emit('close');
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||||
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
|
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'col',
|
default: 'col',
|
||||||
},
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
@@ -18,10 +22,11 @@ const handleClick = () => {
|
|||||||
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex w-full gap-3 px-6 py-5"
|
class="flex w-full gap-3 py-5"
|
||||||
:class="
|
:class="[
|
||||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
|
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
|
||||||
"
|
selectable ? 'px-10 py-6' : 'px-6',
|
||||||
|
]"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ const toggleBlock = () => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||||
>
|
>
|
||||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||||
<div class="w-full mx-auto max-w-[650px]">
|
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
:items="breadcrumbItems"
|
:items="breadcrumbItems"
|
||||||
@@ -98,8 +98,8 @@ const toggleBlock = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
|
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||||
<div class="w-full py-4 mx-auto max-w-[650px]">
|
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -107,7 +107,7 @@ const toggleBlock = () => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="slots.sidebar"
|
v-if="slots.sidebar"
|
||||||
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
|
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||||
>
|
>
|
||||||
<slot name="sidebar" />
|
<slot name="sidebar" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const emit = defineEmits([
|
|||||||
<template>
|
<template>
|
||||||
<header class="sticky top-0 z-10">
|
<header class="sticky top-0 z-10">
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
|
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||||
>
|
>
|
||||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
|
|||||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||||
"
|
"
|
||||||
:show-clear-button="!hasActiveSegments"
|
:show-clear-button="!hasActiveSegments"
|
||||||
class="max-w-[960px] px-6"
|
class="max-w-[60rem] px-6"
|
||||||
@open-filter="emit('openFilter')"
|
@open-filter="emit('openFilter')"
|
||||||
@clear-filters="emit('clearFilters')"
|
@clear-filters="emit('clearFilters')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const openFilter = () => {
|
|||||||
@clear-filters="emit('clearFilters')"
|
@clear-filters="emit('clearFilters')"
|
||||||
/>
|
/>
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
<div class="w-full mx-auto max-w-[960px]">
|
<div class="w-full mx-auto max-w-[60rem]">
|
||||||
<ContactsActiveFiltersPreview
|
<ContactsActiveFiltersPreview
|
||||||
v-if="
|
v-if="
|
||||||
(hasAppliedFilters || !isNotSegmentView) &&
|
(hasAppliedFilters || !isNotSegmentView) &&
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ defineProps({
|
|||||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
|
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||||
<div class="w-full max-w-[960px] mx-auto lg:px-6">
|
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
|
||||||
<div
|
<div
|
||||||
v-if="showHeaderTitle"
|
v-if="showHeaderTitle"
|
||||||
class="flex items-center justify-start h-20 gap-2"
|
class="flex items-center justify-start h-20 gap-2"
|
||||||
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||||
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
|
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
|
||||||
<slot name="content" />
|
<slot name="content" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+7
-7
@@ -52,7 +52,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
|
class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3>
|
<h3>
|
||||||
@@ -75,7 +75,7 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between w-full gap-4 py-2">
|
<div class="flex justify-between w-full gap-4 py-2">
|
||||||
<label
|
<label
|
||||||
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
|
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-slate-900 dark:text-slate-50"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
t(
|
t(
|
||||||
@@ -90,9 +90,9 @@ onMounted(() => {
|
|||||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
|
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="w-[220px]"
|
class="w-[13.75rem]"
|
||||||
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
|
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
|
||||||
custom-text-area-class="max-h-[150px]"
|
custom-text-area-class="max-h-[9.375rem]"
|
||||||
auto-height
|
auto-height
|
||||||
min-height="3rem"
|
min-height="3rem"
|
||||||
/>
|
/>
|
||||||
@@ -108,12 +108,12 @@ onMounted(() => {
|
|||||||
:label="
|
:label="
|
||||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
|
||||||
"
|
"
|
||||||
custom-label-class="min-w-[120px]"
|
custom-label-class="min-w-[7.5rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between w-full gap-3 py-2">
|
<div class="flex justify-between w-full gap-3 py-2">
|
||||||
<label
|
<label
|
||||||
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
|
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-slate-900 dark:text-slate-50"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
|
||||||
@@ -126,7 +126,7 @@ onMounted(() => {
|
|||||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
|
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="w-[224px]"
|
class="w-[14rem]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ const handleCategory = async formData => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
class="w-[25rem] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
|
||||||
>
|
>
|
||||||
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
|
||||||
{{
|
{{
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ defineExpose({ state, isSubmitDisabled });
|
|||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
|
||||||
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||||
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
|
||||||
/>
|
/>
|
||||||
<EmojiInput
|
<EmojiInput
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@ const handleDeletePortal = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="activePortal"
|
v-else-if="activePortal"
|
||||||
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
|
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
|
||||||
>
|
>
|
||||||
<PortalBaseSettings
|
<PortalBaseSettings
|
||||||
:active-portal="activePortal"
|
:active-portal="activePortal"
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
|
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
createNewContact,
|
createNewContact,
|
||||||
fetchContactableInboxes,
|
fetchContactableInboxes,
|
||||||
processContactableInboxes,
|
processContactableInboxes,
|
||||||
|
mergeInboxDetails,
|
||||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||||
|
|
||||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||||
@@ -47,6 +48,7 @@ const currentUser = useMapGetter('getCurrentUser');
|
|||||||
const globalConfig = useMapGetter('globalConfig/get');
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||||
const messageSignature = useMapGetter('getMessageSignature');
|
const messageSignature = useMapGetter('getMessageSignature');
|
||||||
|
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||||
|
|
||||||
const sendWithSignature = computed(() =>
|
const sendWithSignature = computed(() =>
|
||||||
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
||||||
@@ -104,7 +106,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
isFetchingInboxes.value = true;
|
isFetchingInboxes.value = true;
|
||||||
try {
|
try {
|
||||||
const contactableInboxes = await fetchContactableInboxes(contact.id);
|
const contactableInboxes = await fetchContactableInboxes(contact.id);
|
||||||
selectedContact.value.contactInboxes = contactableInboxes;
|
// Merge the processed contactableInboxes with the inboxesList
|
||||||
|
selectedContact.value.contactInboxes = mergeInboxDetails(
|
||||||
|
contactableInboxes,
|
||||||
|
inboxesList.value
|
||||||
|
);
|
||||||
|
|
||||||
isFetchingInboxes.value = false;
|
isFetchingInboxes.value = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
isFetchingInboxes.value = false;
|
isFetchingInboxes.value = false;
|
||||||
@@ -162,9 +169,12 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
if (activeContact.value && props.contactId) {
|
if (activeContact.value && props.contactId) {
|
||||||
const contactInboxes = activeContact.value?.contactInboxes || [];
|
const contactInboxes = activeContact.value?.contactInboxes || [];
|
||||||
|
// First process the contactable inboxes to get the right structure
|
||||||
|
const processedInboxes = processContactableInboxes(contactInboxes);
|
||||||
|
// Then Merge processedInboxes with the inboxes list
|
||||||
selectedContact.value = {
|
selectedContact.value = {
|
||||||
...activeContact.value,
|
...activeContact.value,
|
||||||
contactInboxes: processContactableInboxes(contactInboxes),
|
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -148,7 +148,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
|
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<WhatsAppOptions
|
<WhatsAppOptions
|
||||||
|
|||||||
+3
-3
@@ -47,10 +47,10 @@ const removeAttachment = id => {
|
|||||||
<div
|
<div
|
||||||
v-for="attachment in filteredImageAttachments"
|
v-for="attachment in filteredImageAttachments"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
class="relative group/image w-[72px] h-[72px]"
|
class="relative group/image w-[4.5rem] h-[4.5rem]"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="object-cover w-[72px] h-[72px] rounded-lg"
|
class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
|
||||||
:src="attachment.thumb"
|
:src="attachment.thumb"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -69,7 +69,7 @@ const removeAttachment = id => {
|
|||||||
<div
|
<div
|
||||||
v-for="attachment in filteredNonImageAttachments"
|
v-for="attachment in filteredNonImageAttachments"
|
||||||
:key="attachment.id"
|
:key="attachment.id"
|
||||||
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
|
class="max-w-[18.75rem] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium text-n-slate-11">
|
<span class="text-sm font-medium text-n-slate-11">
|
||||||
{{ fileNameWithEllipsis(attachment.resource) }}
|
{{ fileNameWithEllipsis(attachment.resource) }}
|
||||||
|
|||||||
+1
-1
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
|
||||||
>
|
>
|
||||||
<ContactSelector
|
<ContactSelector
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
|
|||||||
+15
@@ -87,6 +87,21 @@ export const processContactableInboxes = inboxes => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
|
||||||
|
if (!inboxesData || !inboxesData.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return inboxesData.map(inboxData => {
|
||||||
|
const matchingInbox =
|
||||||
|
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
|
||||||
|
return {
|
||||||
|
...camelcaseKeys(matchingInbox, { deep: true }),
|
||||||
|
...inboxData,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const prepareAttachmentPayload = (
|
export const prepareAttachmentPayload = (
|
||||||
attachedFiles,
|
attachedFiles,
|
||||||
directUploadsEnabled
|
directUploadsEnabled
|
||||||
|
|||||||
+147
@@ -110,6 +110,153 @@ describe('composeConversationHelper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('mergeInboxDetails', () => {
|
||||||
|
it('returns empty array if inboxesData is empty or null', () => {
|
||||||
|
expect(helpers.mergeInboxDetails(null)).toEqual([]);
|
||||||
|
expect(helpers.mergeInboxDetails([])).toEqual([]);
|
||||||
|
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges inbox data with matching inboxes from the list', () => {
|
||||||
|
const inboxesData = [
|
||||||
|
{ id: 1, sourceId: 'source1' },
|
||||||
|
{ id: 2, sourceId: 'source2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inboxesList = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Inbox 1',
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
channel_id: 10,
|
||||||
|
phone_number: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Inbox 2',
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
channel_id: 20,
|
||||||
|
phone_number: '+1234567890',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Inbox 3',
|
||||||
|
channel_type: 'Channel::Api',
|
||||||
|
channel_id: 30,
|
||||||
|
phone_number: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
|
||||||
|
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
sourceId: 'source1',
|
||||||
|
name: 'Inbox 1',
|
||||||
|
channelType: 'Channel::Email',
|
||||||
|
channelId: 10,
|
||||||
|
phoneNumber: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
id: 2,
|
||||||
|
sourceId: 'source2',
|
||||||
|
name: 'Inbox 2',
|
||||||
|
channelType: 'Channel::Whatsapp',
|
||||||
|
channelId: 20,
|
||||||
|
phoneNumber: '+1234567890',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles inboxes not found in the list', () => {
|
||||||
|
const inboxesData = [
|
||||||
|
{ id: 1, sourceId: 'source1' },
|
||||||
|
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
|
||||||
|
];
|
||||||
|
|
||||||
|
const inboxesList = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Inbox 1',
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
|
||||||
|
|
||||||
|
expect(result.length).toBe(2);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
sourceId: 'source1',
|
||||||
|
name: 'Inbox 1',
|
||||||
|
channelType: 'Channel::Email',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[1]).toMatchObject({
|
||||||
|
id: 99,
|
||||||
|
sourceId: 'source99',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[1].name).toBeUndefined();
|
||||||
|
expect(result[1].channelType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('camelcases properties from inboxesList', () => {
|
||||||
|
const inboxesData = [{ id: 1, sourceId: 'source1' }];
|
||||||
|
|
||||||
|
const inboxesList = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Inbox 1',
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
avatar_url: 'https://example.com/avatar.png',
|
||||||
|
working_hours: [
|
||||||
|
{
|
||||||
|
day_of_week: 1,
|
||||||
|
closed_all_day: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
|
||||||
|
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
sourceId: 'source1',
|
||||||
|
name: 'Inbox 1',
|
||||||
|
channelType: 'Channel::Email',
|
||||||
|
avatarUrl: 'https://example.com/avatar.png',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result[0].workingHours[0]).toMatchObject({
|
||||||
|
dayOfWeek: 1,
|
||||||
|
closedAllDay: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves original properties when they conflict with inboxesList', () => {
|
||||||
|
const inboxesData = [
|
||||||
|
{ id: 1, sourceId: 'source1', name: 'Original Name' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inboxesList = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'List Name',
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
|
||||||
|
|
||||||
|
expect(result[0].name).toBe('Original Name');
|
||||||
|
expect(result[0].channelType).toBe('Channel::Email');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('prepareAttachmentPayload', () => {
|
describe('prepareAttachmentPayload', () => {
|
||||||
it('prepares direct upload files', () => {
|
it('prepares direct upload files', () => {
|
||||||
const files = [{ blobSignedId: 'signed1' }];
|
const files = [{ blobSignedId: 'signed1' }];
|
||||||
|
|||||||
@@ -99,45 +99,50 @@ const computedJustify = computed(() => {
|
|||||||
const STYLE_CONFIG = {
|
const STYLE_CONFIG = {
|
||||||
colors: {
|
colors: {
|
||||||
blue: {
|
blue: {
|
||||||
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
|
solid:
|
||||||
|
'bg-n-brand text-white hover:enabled:brightness-110 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
|
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 outline-transparent',
|
||||||
outline: 'text-n-blue-text outline-n-blue-border',
|
outline: 'text-n-blue-text outline-n-blue-border',
|
||||||
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
|
ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||||
link: 'text-n-blue-text hover:underline outline-transparent',
|
link: 'text-n-blue-text hover:enabled:underline outline-transparent',
|
||||||
},
|
},
|
||||||
ruby: {
|
ruby: {
|
||||||
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
|
solid:
|
||||||
|
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
|
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 outline-transparent',
|
||||||
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
|
outline: 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||||
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
|
ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||||
link: 'text-n-ruby-9 hover:underline outline-transparent',
|
link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
|
||||||
},
|
},
|
||||||
amber: {
|
amber: {
|
||||||
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
|
solid:
|
||||||
|
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
|
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 outline-transparent',
|
||||||
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
|
outline:
|
||||||
link: 'text-n-amber-9 hover:underline outline-transparent',
|
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 outline-n-amber-9',
|
||||||
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
|
link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
|
||||||
|
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||||
},
|
},
|
||||||
slate: {
|
slate: {
|
||||||
solid:
|
solid:
|
||||||
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 hover:enabled:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
|
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 outline-transparent',
|
||||||
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
|
outline: 'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10',
|
||||||
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
|
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
|
||||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
|
ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||||
},
|
},
|
||||||
teal: {
|
teal: {
|
||||||
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
|
solid:
|
||||||
|
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
|
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 outline-transparent',
|
||||||
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
|
outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 outline-n-teal-9',
|
||||||
link: 'text-n-teal-9 hover:underline outline-transparent',
|
link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
|
||||||
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
|
ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sizes: {
|
sizes: {
|
||||||
@@ -171,7 +176,7 @@ const STYLE_CONFIG = {
|
|||||||
center: 'justify-center',
|
center: 'justify-center',
|
||||||
end: 'justify-end',
|
end: 'justify-end',
|
||||||
},
|
},
|
||||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
|
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = computed(() => {
|
const variantClasses = computed(() => {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const handlePageChange = event => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||||
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
<header class="sticky top-0 z-10 px-6 xl:px-0">
|
||||||
<div class="w-full max-w-[960px] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
<div
|
<div
|
||||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||||
>
|
>
|
||||||
@@ -96,7 +96,7 @@ const handlePageChange = event => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
|
||||||
<div class="w-full max-w-[960px] mx-auto py-4">
|
<div class="w-full max-w-[60rem] mx-auto py-4">
|
||||||
<slot v-if="!showPaywall" name="controls" />
|
<slot v-if="!showPaywall" name="controls" />
|
||||||
<div
|
<div
|
||||||
v-if="isFetching"
|
v-if="isFetching"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
|
|||||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -46,14 +47,27 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
selectable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action', 'navigate']);
|
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => props.isSelected,
|
||||||
|
set: () => emit('select', props.id),
|
||||||
|
});
|
||||||
|
|
||||||
const statusAction = computed(() => {
|
const statusAction = computed(() => {
|
||||||
if (props.status === 'pending') {
|
if (props.status === 'pending') {
|
||||||
return [
|
return [
|
||||||
@@ -102,8 +116,17 @@ const handleDocumentableClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardLayout :class="{ 'rounded-md': compact }">
|
<CardLayout
|
||||||
<div class="flex justify-between w-full gap-1">
|
selectable
|
||||||
|
class="relative"
|
||||||
|
:class="{ 'rounded-md': compact }"
|
||||||
|
@mouseenter="emit('hover', true)"
|
||||||
|
@mouseleave="emit('hover', false)"
|
||||||
|
>
|
||||||
|
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
|
||||||
|
<Checkbox v-model="modelValue" />
|
||||||
|
</div>
|
||||||
|
<div class="flex relative justify-between w-full gap-1">
|
||||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
<span class="text-base text-n-slate-12 line-clamp-1">
|
||||||
{{ question }}
|
{{ question }}
|
||||||
</span>
|
</span>
|
||||||
@@ -148,7 +171,7 @@ const handleDocumentableClick = () => {
|
|||||||
v-if="documentable.type === 'Captain::Document'"
|
v-if="documentable.type === 'Captain::Document'"
|
||||||
class="inline-flex items-center gap-1 truncate over"
|
class="inline-flex items-center gap-1 truncate over"
|
||||||
>
|
>
|
||||||
<i class="i-ph-chat-circle-dots text-base" />
|
<i class="i-ph-files-light text-base" />
|
||||||
<span class="max-w-96 truncate" :title="documentable.name">
|
<span class="max-w-96 truncate" :title="documentable.name">
|
||||||
{{ documentable.name }}
|
{{ documentable.name }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
bulkIds: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['deleteSuccess']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const bulkDeleteDialogRef = ref(null);
|
||||||
|
const i18nKey = computed(() => props.type.toUpperCase());
|
||||||
|
|
||||||
|
const handleBulkDelete = async ids => {
|
||||||
|
if (!ids) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.dispatch(
|
||||||
|
'captainBulkActions/handleBulkDelete',
|
||||||
|
Array.from(props.bulkIds)
|
||||||
|
);
|
||||||
|
|
||||||
|
emit('deleteSuccess');
|
||||||
|
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDialogConfirm = async () => {
|
||||||
|
await handleBulkDelete(Array.from(props.bulkIds));
|
||||||
|
bulkDeleteDialogRef.value?.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ dialogRef: bulkDeleteDialogRef });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="bulkDeleteDialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="t(`CAPTAIN.${i18nKey}.BULK_DELETE.TITLE`)"
|
||||||
|
:description="t(`CAPTAIN.${i18nKey}.BULK_DELETE.DESCRIPTION`)"
|
||||||
|
:confirm-button-label="t(`CAPTAIN.${i18nKey}.BULK_DELETE.CONFIRM`)"
|
||||||
|
@confirm="handleDialogConfirm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -27,7 +27,7 @@ const openBilling = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center"
|
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
|
||||||
>
|
>
|
||||||
<BasePaywallModal
|
<BasePaywallModal
|
||||||
class="mx-auto"
|
class="mx-auto"
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup>
|
||||||
|
import Checkbox from './Checkbox.vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const defaultValue = ref(false);
|
||||||
|
const isChecked = ref(false);
|
||||||
|
const checkedValue = ref(true);
|
||||||
|
const indeterminateValue = ref(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Story title="Components/Checkbox" :layout="{ type: 'grid', width: '250px' }">
|
||||||
|
<Variant title="States">
|
||||||
|
<div class="p-2 space-y-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span>Default:</span>
|
||||||
|
<Checkbox v-model="defaultValue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span>Checked:</span>
|
||||||
|
<Checkbox v-model="checkedValue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span>Indeterminate:</span>
|
||||||
|
<Checkbox v-model="indeterminateValue" indeterminate />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span>Indeterminate disabled:</span>
|
||||||
|
<Checkbox v-model="indeterminateValue" indeterminate disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span>Disabled:</span>
|
||||||
|
<Checkbox v-model="defaultValue" disabled />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<span>Disabled Checked:</span>
|
||||||
|
<Checkbox v-model="isChecked" disabled />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Variant>
|
||||||
|
</Story>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
indeterminate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
const modelValue = defineModel('modelValue', {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleChange = event => {
|
||||||
|
modelValue.value = event.target.checked;
|
||||||
|
emit('change', event);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative w-4 h-4">
|
||||||
|
<input
|
||||||
|
:checked="modelValue"
|
||||||
|
:indeterminate="indeterminate"
|
||||||
|
type="checkbox"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="peer absolute inset-0 z-10 h-4 w-4 disabled:opacity-50 appearance-none rounded border border-n-slate-6 ring-transparent transition-all duration-200 checked:border-n-brand checked:bg-n-brand dark:border-gray-600 dark:checked:border-n-brand indeterminate:border-n-brand indeterminate:bg-n-brand hover:enabled:bg-n-blue-border cursor-pointer"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
<!-- Checkmark SVG -->
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-checked:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!-- Minus/Indeterminate SVG -->
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-indeterminate:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 7L11 7"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import CopilotInput from './CopilotInput.vue';
|
|||||||
import CopilotLoader from './CopilotLoader.vue';
|
import CopilotLoader from './CopilotLoader.vue';
|
||||||
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
import CopilotAgentMessage from './CopilotAgentMessage.vue';
|
||||||
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
||||||
|
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||||
import Icon from '../icon/Icon.vue';
|
import Icon from '../icon/Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -26,9 +27,17 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
assistants: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
activeAssistant: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage', 'reset']);
|
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
|
||||||
|
|
||||||
const COPILOT_USER_ROLES = ['assistant', 'system'];
|
const COPILOT_USER_ROLES = ['assistant', 'system'];
|
||||||
|
|
||||||
@@ -97,14 +106,18 @@ watch(
|
|||||||
|
|
||||||
<CopilotLoader v-if="isCaptainTyping" />
|
<CopilotLoader v-if="isCaptainTyping" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div v-if="!messages.length" class="flex-1 px-3 py-3 space-y-1">
|
<div
|
||||||
|
v-if="!messages.length"
|
||||||
|
class="h-full w-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div class="h-fit px-3 py-3 space-y-1">
|
||||||
<span class="text-xs text-n-slate-10">
|
<span class="text-xs text-n-slate-10">
|
||||||
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
|
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-for="prompt in promptOptions"
|
v-for="prompt in promptOptions"
|
||||||
:key="prompt"
|
:key="prompt.label"
|
||||||
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
|
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
|
||||||
@click="() => useSuggestion(prompt)"
|
@click="() => useSuggestion(prompt)"
|
||||||
>
|
>
|
||||||
@@ -112,7 +125,17 @@ watch(
|
|||||||
<Icon icon="i-lucide-chevron-right" />
|
<Icon icon="i-lucide-chevron-right" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-3 mt-px mb-2 flex flex-col items-end flex-1">
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-3 mt-px mb-2">
|
||||||
|
<div class="flex items-center gap-2 justify-between w-full mb-1">
|
||||||
|
<ToggleCopilotAssistant
|
||||||
|
v-if="assistants.length"
|
||||||
|
:assistants="assistants"
|
||||||
|
:active-assistant="activeAssistant"
|
||||||
|
@set-assistant="$event => emit('setAssistant', $event)"
|
||||||
|
/>
|
||||||
|
<div v-else />
|
||||||
<button
|
<button
|
||||||
v-if="messages.length"
|
v-if="messages.length"
|
||||||
class="text-xs flex items-center gap-1 hover:underline"
|
class="text-xs flex items-center gap-1 hover:underline"
|
||||||
@@ -121,8 +144,8 @@ watch(
|
|||||||
<i class="i-lucide-refresh-ccw" />
|
<i class="i-lucide-refresh-ccw" />
|
||||||
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
|
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<CopilotInput class="mb-1 flex-1 w-full" @send="sendMessage" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||||
|
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||||
|
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||||
|
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assistants: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
activeAssistant: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['setAssistant']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const activeAssistantLabel = computed(() => {
|
||||||
|
return props.activeAssistant
|
||||||
|
? props.activeAssistant.name
|
||||||
|
: t('CAPTAIN.COPILOT.SELECT_ASSISTANT');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<DropdownContainer>
|
||||||
|
<template #trigger="{ toggle, isOpen }">
|
||||||
|
<Button
|
||||||
|
:label="activeAssistantLabel"
|
||||||
|
icon="i-woot-captain"
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
xs
|
||||||
|
:class="{ 'bg-n-alpha-2': isOpen }"
|
||||||
|
@click="toggle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<DropdownBody class="bottom-9 min-w-64 z-50" strong>
|
||||||
|
<DropdownSection class="max-h-80 overflow-scroll">
|
||||||
|
<DropdownItem
|
||||||
|
v-for="assistant in assistants"
|
||||||
|
:key="assistant.id"
|
||||||
|
class="!items-start !gap-1 flex-col cursor-pointer"
|
||||||
|
@click="() => emit('setAssistant', assistant)"
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<div class="flex gap-1 justify-between w-full">
|
||||||
|
<div class="items-start flex gap-1 flex-col">
|
||||||
|
<span class="text-n-slate-12 text-sm">
|
||||||
|
{{ assistant.name }}
|
||||||
|
</span>
|
||||||
|
<span class="line-clamp-2 text-n-slate-11 text-xs">
|
||||||
|
{{ assistant.description }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="assistant.id === activeAssistant?.id"
|
||||||
|
class="flex items-center justify-center flex-shrink-0 w-4 h-4 rounded-full bg-n-slate-12 dark:bg-n-slate-11"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="i-lucide-check text-white dark:text-n-slate-1 size-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownSection>
|
||||||
|
</DropdownBody>
|
||||||
|
</DropdownContainer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -19,7 +19,7 @@ const beforeClass = computed(() => {
|
|||||||
|
|
||||||
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
|
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
|
||||||
// https://issues.chromium.org/issues/40835530
|
// https://issues.chromium.org/issues/40835530
|
||||||
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
|
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:rounded-xl before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { computed } from 'vue';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useOperators } from './operators';
|
import { useOperators } from './operators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { buildAttributesFilterTypes } from './helper/filterHelper.js';
|
import {
|
||||||
|
buildAttributesFilterTypes,
|
||||||
|
CONTACT_ATTRIBUTES,
|
||||||
|
} from './helper/filterHelper.js';
|
||||||
import countries from 'shared/constants/countries.js';
|
import countries from 'shared/constants/countries.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +62,11 @@ export function useContactFilterContext() {
|
|||||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||||
*/
|
*/
|
||||||
const customFilterTypes = computed(() =>
|
const customFilterTypes = computed(() =>
|
||||||
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
|
buildAttributesFilterTypes(
|
||||||
|
contactAttributes.value,
|
||||||
|
getOperatorTypes,
|
||||||
|
'contact'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,8 +74,8 @@ export function useContactFilterContext() {
|
|||||||
*/
|
*/
|
||||||
const filterTypes = computed(() => [
|
const filterTypes = computed(() => [
|
||||||
{
|
{
|
||||||
attributeKey: 'name',
|
attributeKey: CONTACT_ATTRIBUTES.NAME,
|
||||||
value: 'name',
|
value: CONTACT_ATTRIBUTES.NAME,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -77,8 +84,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'email',
|
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
|
||||||
value: 'email',
|
value: CONTACT_ATTRIBUTES.EMAIL,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -87,8 +94,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'phone_number',
|
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||||
value: 'phone_number',
|
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -97,8 +104,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'identifier',
|
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||||
value: 'identifier',
|
value: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -107,8 +114,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'country_code',
|
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||||
value: 'country_code',
|
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -118,8 +125,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'additional',
|
attributeModel: 'additional',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'city',
|
attributeKey: CONTACT_ATTRIBUTES.CITY,
|
||||||
value: 'city',
|
value: CONTACT_ATTRIBUTES.CITY,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -128,8 +135,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'created_at',
|
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||||
value: 'created_at',
|
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||||
inputType: 'date',
|
inputType: 'date',
|
||||||
@@ -138,8 +145,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'last_activity_at',
|
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||||
value: 'last_activity_at',
|
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||||
inputType: 'date',
|
inputType: 'date',
|
||||||
@@ -148,8 +155,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'referer',
|
attributeKey: CONTACT_ATTRIBUTES.REFERER,
|
||||||
value: 'referer',
|
value: CONTACT_ATTRIBUTES.REFERER,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -158,8 +165,8 @@ export function useContactFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'blocked',
|
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
|
||||||
value: 'blocked',
|
value: CONTACT_ATTRIBUTES.BLOCKED,
|
||||||
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||||
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Standard attributes of the conversation model
|
||||||
|
*/
|
||||||
|
export const CONVERSATION_ATTRIBUTES = {
|
||||||
|
STATUS: 'status',
|
||||||
|
PRIORITY: 'priority',
|
||||||
|
ASSIGNEE_ID: 'assignee_id',
|
||||||
|
INBOX_ID: 'inbox_id',
|
||||||
|
TEAM_ID: 'team_id',
|
||||||
|
DISPLAY_ID: 'display_id',
|
||||||
|
CAMPAIGN_ID: 'campaign_id',
|
||||||
|
LABELS: 'labels',
|
||||||
|
BROWSER_LANGUAGE: 'browser_language',
|
||||||
|
COUNTRY_CODE: 'country_code',
|
||||||
|
REFERER: 'referer',
|
||||||
|
CREATED_AT: 'created_at',
|
||||||
|
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTACT_ATTRIBUTES = {
|
||||||
|
NAME: 'name',
|
||||||
|
EMAIL: 'email',
|
||||||
|
PHONE_NUMBER: 'phone_number',
|
||||||
|
IDENTIFIER: 'identifier',
|
||||||
|
COUNTRY_CODE: 'country_code',
|
||||||
|
CITY: 'city',
|
||||||
|
CREATED_AT: 'created_at',
|
||||||
|
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||||
|
REFERER: 'referer',
|
||||||
|
BLOCKED: 'blocked',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines the input type for a custom attribute based on its key
|
* Determines the input type for a custom attribute based on its key
|
||||||
* @param {string} key - The attribute display type key
|
* @param {string} key - The attribute display type key
|
||||||
@@ -20,24 +52,37 @@ export const getCustomAttributeInputType = key => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds filter types for custom attributes
|
* Builds filter types for custom attributes
|
||||||
|
* This also removes any conflicting attributes
|
||||||
* @param {Array} attributes - The attributes array
|
* @param {Array} attributes - The attributes array
|
||||||
* @param {Function} getOperatorTypes - Function to get operator types
|
* @param {Function} getOperatorTypes - Function to get operator types
|
||||||
* @returns {Array} Array of filter types
|
* @returns {Array} Array of filter types
|
||||||
*/
|
*/
|
||||||
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
|
export const buildAttributesFilterTypes = (
|
||||||
return attributes.map(attr => ({
|
attributes,
|
||||||
attributeKey: attr.attributeKey,
|
getOperatorTypes,
|
||||||
value: attr.attributeKey,
|
filterModel = 'conversation'
|
||||||
attributeName: attr.attributeDisplayName,
|
) => {
|
||||||
label: attr.attributeDisplayName,
|
const standardAttributes = Object.values(
|
||||||
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
filterModel === 'conversation'
|
||||||
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
? CONVERSATION_ATTRIBUTES
|
||||||
options:
|
: CONTACT_ATTRIBUTES
|
||||||
attr.attributeDisplayType === 'list'
|
);
|
||||||
? attr.attributeValues.map(item => ({ id: item, name: item }))
|
|
||||||
: [],
|
return attributes
|
||||||
attributeModel: 'customAttributes',
|
.filter(attr => !standardAttributes.includes(attr.attributeKey))
|
||||||
}));
|
.map(attr => ({
|
||||||
|
attributeKey: attr.attributeKey,
|
||||||
|
value: attr.attributeKey,
|
||||||
|
attributeName: attr.attributeDisplayName,
|
||||||
|
label: attr.attributeDisplayName,
|
||||||
|
inputType: getCustomAttributeInputType(attr.attributeDisplayType),
|
||||||
|
filterOperators: getOperatorTypes(attr.attributeDisplayType),
|
||||||
|
options:
|
||||||
|
attr.attributeDisplayType === 'list'
|
||||||
|
? attr.attributeValues.map(item => ({ id: item, name: item }))
|
||||||
|
: [],
|
||||||
|
attributeModel: 'customAttributes',
|
||||||
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useOperators } from './operators';
|
import { useOperators } from './operators';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useChannelIcon } from 'next/icon/provider';
|
import { useChannelIcon } from 'next/icon/provider';
|
||||||
import { buildAttributesFilterTypes } from './helper/filterHelper';
|
import {
|
||||||
|
buildAttributesFilterTypes,
|
||||||
|
CONVERSATION_ATTRIBUTES,
|
||||||
|
} from './helper/filterHelper';
|
||||||
import countries from 'shared/constants/countries.js';
|
import countries from 'shared/constants/countries.js';
|
||||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||||
|
|
||||||
@@ -70,7 +73,11 @@ export function useConversationFilterContext() {
|
|||||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||||
*/
|
*/
|
||||||
const customFilterTypes = computed(() =>
|
const customFilterTypes = computed(() =>
|
||||||
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
|
buildAttributesFilterTypes(
|
||||||
|
conversationAttributes.value,
|
||||||
|
getOperatorTypes,
|
||||||
|
'conversation'
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,8 +85,8 @@ export function useConversationFilterContext() {
|
|||||||
*/
|
*/
|
||||||
const filterTypes = computed(() => [
|
const filterTypes = computed(() => [
|
||||||
{
|
{
|
||||||
attributeKey: 'status',
|
attributeKey: CONVERSATION_ATTRIBUTES.STATUS,
|
||||||
value: 'status',
|
value: CONVERSATION_ATTRIBUTES.STATUS,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
|
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
|
||||||
label: t('FILTER.ATTRIBUTES.STATUS'),
|
label: t('FILTER.ATTRIBUTES.STATUS'),
|
||||||
inputType: 'multiSelect',
|
inputType: 'multiSelect',
|
||||||
@@ -94,8 +101,24 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'assignee_id',
|
attributeKey: CONVERSATION_ATTRIBUTES.PRIORITY,
|
||||||
value: 'assignee_id',
|
value: CONVERSATION_ATTRIBUTES.PRIORITY,
|
||||||
|
attributeName: t('FILTER.ATTRIBUTES.PRIORITY'),
|
||||||
|
label: t('FILTER.ATTRIBUTES.PRIORITY'),
|
||||||
|
inputType: 'multiSelect',
|
||||||
|
options: ['low', 'medium', 'high', 'urgent'].map(id => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: t(`CONVERSATION.PRIORITY.OPTIONS.${id.toUpperCase()}`),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
dataType: 'text',
|
||||||
|
filterOperators: equalityOperators.value,
|
||||||
|
attributeModel: 'standard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
attributeKey: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
|
||||||
|
value: CONVERSATION_ATTRIBUTES.ASSIGNEE_ID,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
attributeName: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||||
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -110,8 +133,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'inbox_id',
|
attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||||
value: 'inbox_id',
|
value: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||||
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -126,8 +149,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'team_id',
|
attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||||
value: 'team_id',
|
value: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||||
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -137,8 +160,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'display_id',
|
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||||
value: 'display_id',
|
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||||
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -147,8 +170,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'campaign_id',
|
attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||||
value: 'campaign_id',
|
value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||||
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -161,8 +184,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'labels',
|
attributeKey: CONVERSATION_ATTRIBUTES.LABELS,
|
||||||
value: 'labels',
|
value: CONVERSATION_ATTRIBUTES.LABELS,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
|
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
|
||||||
label: t('FILTER.ATTRIBUTES.LABELS'),
|
label: t('FILTER.ATTRIBUTES.LABELS'),
|
||||||
inputType: 'multiSelect',
|
inputType: 'multiSelect',
|
||||||
@@ -185,8 +208,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'browser_language',
|
attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||||
value: 'browser_language',
|
value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||||
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -196,8 +219,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'additional',
|
attributeModel: 'additional',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'country_code',
|
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||||
value: 'country_code',
|
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||||
inputType: 'searchSelect',
|
inputType: 'searchSelect',
|
||||||
@@ -207,8 +230,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'additional',
|
attributeModel: 'additional',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'referer',
|
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||||
value: 'referer',
|
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||||
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||||
inputType: 'plainText',
|
inputType: 'plainText',
|
||||||
@@ -217,8 +240,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'additional',
|
attributeModel: 'additional',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'created_at',
|
attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||||
value: 'created_at',
|
value: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||||
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||||
inputType: 'date',
|
inputType: 'date',
|
||||||
@@ -227,8 +250,8 @@ export function useConversationFilterContext() {
|
|||||||
attributeModel: 'standard',
|
attributeModel: 'standard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
attributeKey: 'last_activity_at',
|
attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||||
value: 'last_activity_at',
|
value: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||||
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
||||||
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
||||||
inputType: 'date',
|
inputType: 'date',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const { t } = useI18n();
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all"
|
class="absolute bg-n-alpha-3 px-4 py-3 border rounded-xl border-n-strong text-n-slate-12 bottom-6 w-52 text-xs backdrop-blur-[100px] shadow-[0px_0px_24px_0px_rgba(0,0,0,0.12)] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all break-all"
|
||||||
:class="{
|
:class="{
|
||||||
'ltr:left-0 rtl:right-0': orientation === ORIENTATION.LEFT,
|
'ltr:left-0 rtl:right-0': orientation === ORIENTATION.LEFT,
|
||||||
'ltr:right-0 rtl:left-0': orientation === ORIENTATION.RIGHT,
|
'ltr:right-0 rtl:left-0': orientation === ORIENTATION.RIGHT,
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const statusToShow = computed(() => {
|
|||||||
if (isRead.value) return MESSAGE_STATUS.READ;
|
if (isRead.value) return MESSAGE_STATUS.READ;
|
||||||
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
if (isDelivered.value) return MESSAGE_STATUS.DELIVERED;
|
||||||
if (isSent.value) return MESSAGE_STATUS.SENT;
|
if (isSent.value) return MESSAGE_STATUS.SENT;
|
||||||
|
if (status.value === MESSAGE_STATUS.FAILED) return MESSAGE_STATUS.FAILED;
|
||||||
|
|
||||||
return MESSAGE_STATUS.PROGRESS;
|
return MESSAGE_STATUS.PROGRESS;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const pageInfo = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ watch(
|
|||||||
"
|
"
|
||||||
trailing-icon
|
trailing-icon
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
class="!h-[30px] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
class="!h-[1.875rem] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
|
||||||
@click="toggleCountryDropdown"
|
@click="toggleCountryDropdown"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ const menuItems = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
|
||||||
>
|
>
|
||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section class="grid gap-2 mt-2 mb-4">
|
||||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const shouldRenderComponent = computed(() => {
|
|||||||
:is="to ? 'router-link' : 'div'"
|
:is="to ? 'router-link' : 'div'"
|
||||||
:to="to"
|
:to="to"
|
||||||
:title="label"
|
:title="label"
|
||||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[151px] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
|
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
|
||||||
:class="{
|
:class="{
|
||||||
'n-blue-text bg-n-alpha-2 active': active,
|
'n-blue-text bg-n-alpha-2 active': active,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const updateValue = () => {
|
|||||||
>
|
>
|
||||||
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
||||||
<span
|
<span
|
||||||
class="absolute top-px left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
|
class="absolute top-[0.07rem] left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
modelValue
|
modelValue
|
||||||
? 'translate-x-2.5 bg-white'
|
? 'translate-x-2.5 bg-white'
|
||||||
|
|||||||
@@ -353,10 +353,11 @@ function setFiltersFromUISettings() {
|
|||||||
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
|
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
|
||||||
const { status, order_by: orderBy } = filterBy;
|
const { status, order_by: orderBy } = filterBy;
|
||||||
activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
|
activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
|
||||||
activeSortBy.value =
|
activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
|
||||||
Object.keys(wootConstants.SORT_BY_TYPE).find(
|
orderBy
|
||||||
sortField => sortField === orderBy
|
)
|
||||||
) || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
|
? orderBy
|
||||||
|
: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitConversationLoaded() {
|
function emitConversationLoaded() {
|
||||||
|
|||||||
@@ -23,33 +23,34 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row"
|
class="ml-0 mr-0 py-8 w-full"
|
||||||
:class="{
|
:class="{
|
||||||
'border-b border-solid border-slate-50 dark:border-slate-700/30':
|
'border-b border-solid border-n-weak/60 dark:border-n-weak': showBorder,
|
||||||
showBorder,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12">
|
<div class="grid grid-cols-1 lg:grid-cols-6 gap-6">
|
||||||
<p
|
<div class="col-span-2 xl:col-span-1">
|
||||||
v-if="title"
|
<p
|
||||||
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
|
v-if="title"
|
||||||
>
|
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
|
||||||
{{ title }}
|
>
|
||||||
</p>
|
{{ title }}
|
||||||
<p
|
</p>
|
||||||
class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2"
|
<p
|
||||||
>
|
class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2"
|
||||||
<slot v-if="subTitle" name="subTitle">
|
>
|
||||||
{{ subTitle }}
|
<slot v-if="subTitle" name="subTitle">
|
||||||
</slot>
|
{{ subTitle }}
|
||||||
</p>
|
</slot>
|
||||||
<p v-if="note">
|
</p>
|
||||||
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
<p v-if="note">
|
||||||
{{ note }}
|
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
||||||
</p>
|
{{ note }}
|
||||||
</div>
|
</p>
|
||||||
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]">
|
</div>
|
||||||
<slot />
|
<div class="col-span-4 lg:col-span-4 2xl:col-span-3">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watchEffect } from 'vue';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
|
||||||
import ConversationAPI from 'dashboard/api/inbox/conversation';
|
import ConversationAPI from 'dashboard/api/inbox/conversation';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { ref } from 'vue';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversationId: {
|
conversationId: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
@@ -13,10 +16,44 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
|
||||||
const messages = ref([]);
|
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||||
|
const inboxAssistant = useMapGetter('getCopilotAssistant');
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const messages = ref([]);
|
||||||
const isCaptainTyping = ref(false);
|
const isCaptainTyping = ref(false);
|
||||||
|
const selectedAssistantId = ref(null);
|
||||||
|
|
||||||
|
const activeAssistant = computed(() => {
|
||||||
|
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||||
|
|
||||||
|
// If the user has selected a specific assistant, it takes first preference for Copilot.
|
||||||
|
if (preferredId) {
|
||||||
|
const preferredAssistant = assistants.value.find(a => a.id === preferredId);
|
||||||
|
// Return the preferred assistant if found, otherwise continue to next cases
|
||||||
|
if (preferredAssistant) return preferredAssistant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the above is not available, the assistant connected to the inbox takes preference.
|
||||||
|
if (inboxAssistant.value) {
|
||||||
|
const inboxMatchedAssistant = assistants.value.find(
|
||||||
|
a => a.id === inboxAssistant.value.id
|
||||||
|
);
|
||||||
|
if (inboxMatchedAssistant) return inboxMatchedAssistant;
|
||||||
|
}
|
||||||
|
// If neither of the above is available, the first assistant in the account takes preference.
|
||||||
|
return assistants.value[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const setAssistant = async assistant => {
|
||||||
|
selectedAssistantId.value = assistant.id;
|
||||||
|
await updateUISettings({
|
||||||
|
preferred_captain_assistant_id: assistant.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
@@ -42,7 +79,7 @@ const sendMessage = async message => {
|
|||||||
}))
|
}))
|
||||||
.slice(0, -1),
|
.slice(0, -1),
|
||||||
message,
|
message,
|
||||||
assistant_id: 16,
|
assistant_id: selectedAssistantId.value,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
@@ -57,6 +94,17 @@ const sendMessage = async message => {
|
|||||||
isCaptainTyping.value = false;
|
isCaptainTyping.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('captainAssistants/get');
|
||||||
|
});
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.conversationId) {
|
||||||
|
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
|
||||||
|
selectedAssistantId.value = activeAssistant.value?.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -65,6 +113,9 @@ const sendMessage = async message => {
|
|||||||
:support-agent="currentUser"
|
:support-agent="currentUser"
|
||||||
:is-captain-typing="isCaptainTyping"
|
:is-captain-typing="isCaptainTyping"
|
||||||
:conversation-inbox-type="conversationInboxType"
|
:conversation-inbox-type="conversationInboxType"
|
||||||
|
:assistants="assistants"
|
||||||
|
:active-assistant="activeAssistant"
|
||||||
|
@set-assistant="setAssistant"
|
||||||
@send-message="sendMessage"
|
@send-message="sendMessage"
|
||||||
@reset="handleReset"
|
@reset="handleReset"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -23,17 +23,16 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
|
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-n-strong"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-woot-25 dark:bg-slate-700 border border-solid border-woot-300 dark:border-woot-400':
|
'bg-woot-25 dark:bg-n-solid-2 border border-solid border-n-blue-border':
|
||||||
active,
|
active,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between items-center px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 rounded-t-[5px] border-b border-solid border-slate-50 dark:border-slate-600"
|
class="flex justify-between items-center rounded-t-md px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 border-b border-solid border-n-strong"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700':
|
'bg-woot-50 border-b border-solid border-n-blue-border': active,
|
||||||
active,
|
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
|
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
|
||||||
|
|||||||
@@ -38,20 +38,20 @@ export default {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 19px;
|
height: 1.188rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition-duration: 200ms;
|
transition-duration: 200ms;
|
||||||
transition-property: background-color;
|
transition-property: background-color;
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
width: 34px;
|
width: 2.125rem;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: var(--w-500);
|
background-color: var(--w-500);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.small {
|
&.small {
|
||||||
width: 22px;
|
width: 1.375rem;
|
||||||
height: 14px;
|
height: 0.875rem;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
height: var(--space-one);
|
height: var(--space-one);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, provide, onMounted, computed } from 'vue';
|
import { ref, useTemplateRef, provide, computed, watch } from 'vue';
|
||||||
import { useEventListener } from '@vueuse/core';
|
import { useElementSize } from '@vueuse/core';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
index: {
|
index: {
|
||||||
@@ -19,6 +19,12 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
const tabsContainer = useTemplateRef('tabsContainer');
|
||||||
|
const tabsList = useTemplateRef('tabsList');
|
||||||
|
|
||||||
|
const { width: containerWidth } = useElementSize(tabsContainer);
|
||||||
|
const { width: listWidth } = useElementSize(tabsList);
|
||||||
|
|
||||||
const hasScroll = ref(false);
|
const hasScroll = ref(false);
|
||||||
|
|
||||||
const activeIndex = computed({
|
const activeIndex = computed({
|
||||||
@@ -34,20 +40,16 @@ provide('updateActiveIndex', index => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const computeScrollWidth = () => {
|
const computeScrollWidth = () => {
|
||||||
// TODO: use useElementSize from vueuse
|
if (tabsContainer.value && tabsList.value) {
|
||||||
const tabElement = document.querySelector('.tabs');
|
hasScroll.value = tabsList.value.scrollWidth > tabsList.value.clientWidth;
|
||||||
if (tabElement) {
|
|
||||||
hasScroll.value = tabElement.scrollWidth > tabElement.clientWidth;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onScrollClick = direction => {
|
const onScrollClick = direction => {
|
||||||
// TODO: use useElementSize from vueuse
|
if (tabsContainer.value && tabsList.value) {
|
||||||
const tabElement = document.querySelector('.tabs');
|
let scrollPosition = tabsList.value.scrollLeft;
|
||||||
if (tabElement) {
|
|
||||||
let scrollPosition = tabElement.scrollLeft;
|
|
||||||
scrollPosition += direction === 'left' ? -100 : 100;
|
scrollPosition += direction === 'left' ? -100 : 100;
|
||||||
tabElement.scrollTo({
|
tabsList.value.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
left: scrollPosition,
|
left: scrollPosition,
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
@@ -55,14 +57,19 @@ const onScrollClick = direction => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEventListener(window, 'resize', computeScrollWidth);
|
// Watch for changes in element sizes with immediate execution
|
||||||
onMounted(() => {
|
watch(
|
||||||
computeScrollWidth();
|
[containerWidth, listWidth],
|
||||||
});
|
() => {
|
||||||
|
computeScrollWidth();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="tabsContainer"
|
||||||
:class="{
|
:class="{
|
||||||
'tabs--container--with-border': border,
|
'tabs--container--with-border': border,
|
||||||
'tabs--container--compact': isCompact,
|
'tabs--container--compact': isCompact,
|
||||||
@@ -76,7 +83,7 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<fluent-icon icon="chevron-left" :size="16" />
|
<fluent-icon icon="chevron-left" :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<ul :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
|
<ul ref="tabsList" :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
|
||||||
<slot />
|
<slot />
|
||||||
</ul>
|
</ul>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -72,26 +72,26 @@ export default {
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
h3 {
|
h3 {
|
||||||
@apply text-woot-500 dark:text-woot-500;
|
@apply text-n-blue-text dark:text-n-blue-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step {
|
.step {
|
||||||
@apply bg-woot-500 dark:bg-woot-500;
|
@apply bg-n-brand dark:bg-n-brand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.over {
|
&.over {
|
||||||
&::after {
|
&::after {
|
||||||
@apply bg-woot-500 dark:bg-woot-500;
|
@apply bg-n-brand dark:bg-n-brand;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step {
|
.step {
|
||||||
@apply bg-woot-500 dark:bg-woot-500;
|
@apply bg-n-brand dark:bg-n-brand;
|
||||||
}
|
}
|
||||||
|
|
||||||
& + .item {
|
& + .item {
|
||||||
&::before {
|
&::before {
|
||||||
@apply bg-woot-500 dark:bg-woot-500;
|
@apply bg-n-brand dark:bg-n-brand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ export default {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.filter {
|
.filter {
|
||||||
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
|
@apply bg-n-slate-3 dark:bg-n-solid-3 p-2 border border-solid border-n-strong dark:border-n-strong rounded-md mb-2;
|
||||||
|
|
||||||
&.is-a-macro {
|
&.is-a-macro {
|
||||||
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none;
|
@apply mb-0 bg-n-slate-2 dark:bg-n-solid-3 p-0 border-0 rounded-none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default {
|
|||||||
getInputErrorClass(errorMessage) {
|
getInputErrorClass(errorMessage) {
|
||||||
return errorMessage
|
return errorMessage
|
||||||
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
|
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
|
||||||
: 'bg-slate-50 dark:bg-slate-800 border-slate-75 dark:border-slate-700/50';
|
: 'bg-n-slate-3 dark:bg-n-solid-3 border-n-strong dark:border-n-strong';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak">
|
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak/60">
|
||||||
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
|
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
|
||||||
{{ headerTitle }}
|
{{ headerTitle }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default {
|
|||||||
const uploadRef = ref(false);
|
const uploadRef = ref(false);
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'Alt+KeyA': {
|
'$mod+Alt+KeyA': {
|
||||||
action: () => {
|
action: () => {
|
||||||
// TODO: This is really hacky, we need to replace the file picker component with
|
// TODO: This is really hacky, we need to replace the file picker component with
|
||||||
// a custom one, where the logic and the component markup is isolated.
|
// a custom one, where the logic and the component markup is isolated.
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between h-[52px] gap-2 ltr:pl-3 rtl:pr-3">
|
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
|
||||||
<EditorModeToggle
|
<EditorModeToggle
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-wrapper {
|
.checkbox-wrapper {
|
||||||
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4;
|
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
|
||||||
|
|
||||||
input[type='checkbox'] {
|
input[type='checkbox'] {
|
||||||
@apply m-0 cursor-pointer;
|
@apply m-0 cursor-pointer;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const showCopilotTab = computed(() =>
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[320px] w-[320px] 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
|
||||||
>
|
>
|
||||||
<div v-if="showCopilotTab" class="p-2">
|
<div v-if="showCopilotTab" class="p-2">
|
||||||
<TabBar
|
<TabBar
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { reactive, computed, onMounted, ref } from 'vue';
|
import { reactive, computed, onMounted, ref } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||||
import validations from './validations';
|
import validations from './validations';
|
||||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
import SearchableDropdown from './SearchableDropdown.vue';
|
import SearchableDropdown from './SearchableDropdown.vue';
|
||||||
|
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversationId: {
|
conversationId: {
|
||||||
@@ -45,7 +47,7 @@ const statusDesiredOrder = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
|
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
|
||||||
|
|
||||||
const formState = reactive({
|
const formState = reactive({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -188,6 +190,7 @@ const createIssue = async () => {
|
|||||||
const { id: issueId } = response.data;
|
const { id: issueId } = response.data;
|
||||||
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
||||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
|
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
|
||||||
|
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = parseLinearAPIErrorResponse(
|
const errorMessage = parseLinearAPIErrorResponse(
|
||||||
@@ -209,7 +212,7 @@ onMounted(getTeams);
|
|||||||
v-model="formState.title"
|
v-model="formState.title"
|
||||||
:class="{ error: v$.title.$error }"
|
:class="{ error: v$.title.$error }"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:styles="{ ...inputStyles, padding: '6px 12px' }"
|
:styles="{ ...inputStyles, padding: '0.375rem 0.75rem' }"
|
||||||
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
|
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
|
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
|
||||||
@@ -221,7 +224,7 @@ onMounted(getTeams);
|
|||||||
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
|
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
|
||||||
<textarea
|
<textarea
|
||||||
v-model="formState.description"
|
v-model="formState.description"
|
||||||
:style="{ ...inputStyles, padding: '8px 12px' }"
|
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
|
||||||
rows="3"
|
rows="3"
|
||||||
class="text-sm"
|
class="text-sm"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||||
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
||||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversationId: {
|
conversationId: {
|
||||||
@@ -85,6 +87,7 @@ const linkIssue = async () => {
|
|||||||
searchQuery.value = '';
|
searchQuery.value = '';
|
||||||
issues.value = [];
|
issues.value = [];
|
||||||
onClose();
|
onClose();
|
||||||
|
useTrack(LINEAR_EVENTS.LINK_ISSUE);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = parseLinearAPIErrorResponse(
|
const errorMessage = parseLinearAPIErrorResponse(
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||||
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
|
||||||
import Issue from './Issue.vue';
|
import Issue from './Issue.vue';
|
||||||
|
import { useTrack } from 'dashboard/composables';
|
||||||
|
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -56,6 +58,7 @@ const unlinkIssue = async linkId => {
|
|||||||
try {
|
try {
|
||||||
isUnlinking.value = true;
|
isUnlinking.value = true;
|
||||||
await LinearAPI.unlinkIssue(linkId);
|
await LinearAPI.unlinkIssue(linkId);
|
||||||
|
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||||
linkedIssue.value = null;
|
linkedIssue.value = null;
|
||||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||||
import { SHORTCUT_KEYS } from './constants';
|
import { SHORTCUT_KEYS, KEYS } from './constants';
|
||||||
import {
|
import {
|
||||||
LAYOUT_QWERTZ,
|
LAYOUT_QWERTZ,
|
||||||
keysToModifyInQWERTZ,
|
keysToModifyInQWERTZ,
|
||||||
} from 'shared/helpers/KeyboardHelpers';
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
import Hotkey from 'dashboard/components/base/Hotkey.vue';
|
import Hotkey from 'dashboard/components/base/Hotkey.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({ show: Boolean });
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(['close']);
|
defineEmits(['close']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const shortcutKeys = SHORTCUT_KEYS;
|
|
||||||
const currentLayout = ref(null);
|
const currentLayout = ref(null);
|
||||||
|
|
||||||
const title = item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
|
const title = computed(
|
||||||
|
() => item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`)
|
||||||
|
);
|
||||||
|
|
||||||
// Added this function to check if the keySet needs a shift key
|
// Added this function to check if the keySet needs a shift key
|
||||||
// This is used to display the shift key in the modal
|
// This is used to display the shift key in the modal
|
||||||
// If the current layout is QWERTZ and the keySet contains a key that needs a shift key
|
// If the current layout is QWERTZ and the keySet contains a key that needs a shift key
|
||||||
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
||||||
// https://github.com/chatwoot/chatwoot/issues/9492
|
// https://github.com/chatwoot/chatwoot/issues/9492
|
||||||
const needsShiftKey = keySet => {
|
const needsShiftKey = computed(
|
||||||
return (
|
() => keySet =>
|
||||||
currentLayout.value === LAYOUT_QWERTZ &&
|
currentLayout.value === LAYOUT_QWERTZ &&
|
||||||
keySet.some(key => keysToModifyInQWERTZ.has(key))
|
keySet.some(key => keysToModifyInQWERTZ.has(key))
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
currentLayout.value = await useDetectKeyboardLayout();
|
currentLayout.value = await useDetectKeyboardLayout();
|
||||||
@@ -55,80 +48,46 @@ onMounted(async () => {
|
|||||||
</h5>
|
</h5>
|
||||||
<div class="flex items-center gap-2 mb-1 ml-2">
|
<div class="flex items-center gap-2 mb-1 ml-2">
|
||||||
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
|
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.WINDOWS_KEY_AND_COMMAND_KEY') }}
|
{{ KEYS.WIN }}
|
||||||
</Hotkey>
|
</Hotkey>
|
||||||
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
|
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
|
{{ KEYS.SLASH }}
|
||||||
</Hotkey>
|
</Hotkey>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 px-8 pt-0 pb-8 gap-x-5 gap-y-3">
|
<div class="grid grid-cols-2 px-8 pt-0 pb-8 gap-x-5 gap-y-3">
|
||||||
<div class="flex justify-between items-center min-w-[25rem]">
|
|
||||||
<h5 class="text-sm text-slate-800 dark:text-slate-100">
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.TITLE.OPEN_CONVERSATION') }}
|
|
||||||
</h5>
|
|
||||||
<div class="flex items-center gap-2 mb-1 ml-2">
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
|
|
||||||
</Hotkey>
|
|
||||||
<Hotkey custom-class="min-h-[28px] w-9 key"> {{ 'J' }} </Hotkey>
|
|
||||||
<span
|
|
||||||
class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
|
|
||||||
>
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
|
|
||||||
</Hotkey>
|
|
||||||
<Hotkey custom-class="w-9 key"> {{ 'K' }} </Hotkey>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center min-w-[25rem]">
|
|
||||||
<h5 class="text-sm text-slate-800 dark:text-slate-100">
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.TITLE.RESOLVE_AND_NEXT') }}
|
|
||||||
</h5>
|
|
||||||
<div class="flex items-center gap-2 mb-1 ml-2">
|
|
||||||
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.WINDOWS_KEY_AND_COMMAND_KEY') }}
|
|
||||||
</Hotkey>
|
|
||||||
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
|
|
||||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
|
|
||||||
</Hotkey>
|
|
||||||
<Hotkey custom-class="w-9 key"> {{ 'E' }} </Hotkey>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
v-for="shortcutKey in shortcutKeys"
|
v-for="shortcut in SHORTCUT_KEYS"
|
||||||
:key="shortcutKey.id"
|
:key="shortcut.id"
|
||||||
class="flex justify-between items-center min-w-[25rem]"
|
class="flex justify-between items-center min-w-[25rem]"
|
||||||
>
|
>
|
||||||
<h5 class="text-sm text-slate-800 min-w-[36px] dark:text-slate-100">
|
<h5 class="text-sm text-slate-800 min-w-[36px] dark:text-slate-100">
|
||||||
{{ title(shortcutKey) }}
|
{{ title(shortcut) }}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="flex items-center gap-2 mb-1 ml-2">
|
<div class="flex items-center gap-2 mb-1 ml-2">
|
||||||
<Hotkey
|
<template v-if="needsShiftKey(shortcut.keySet)">
|
||||||
v-if="needsShiftKey(shortcutKey.keySet)"
|
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
|
||||||
custom-class="min-h-[28px] min-w-[36px] key"
|
{{ KEYS.SHIFT }}
|
||||||
>
|
</Hotkey>
|
||||||
{{ 'Shift' }}
|
</template>
|
||||||
</Hotkey>
|
|
||||||
<Hotkey
|
<template v-for="(key, index) in shortcut.displayKeys" :key="index">
|
||||||
:class="{ 'min-w-[60px]': shortcutKey.firstKey !== 'Up' }"
|
<template v-if="key !== KEYS.SLASH">
|
||||||
custom-class="min-h-[28px] normal-case key"
|
<Hotkey
|
||||||
>
|
custom-class="min-h-[28px] min-w-[36px] key normal-case"
|
||||||
{{ shortcutKey.firstKey }}
|
>
|
||||||
</Hotkey>
|
{{ key }}
|
||||||
<Hotkey
|
</Hotkey>
|
||||||
:class="{ 'normal-case': shortcutKey.secondKey === 'Down' }"
|
</template>
|
||||||
custom-class="min-h-[28px] min-w-[36px] key"
|
<span
|
||||||
>
|
v-else
|
||||||
{{ shortcutKey.secondKey }}
|
class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
|
||||||
</Hotkey>
|
>
|
||||||
|
{{ key }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,86 +1,95 @@
|
|||||||
|
export const KEYS = {
|
||||||
|
ALT: 'Alt / ⌥',
|
||||||
|
WIN: 'Win / ⌘',
|
||||||
|
SHIFT: 'Shift',
|
||||||
|
SLASH: '/',
|
||||||
|
UP: 'Up',
|
||||||
|
DOWN: 'Down',
|
||||||
|
};
|
||||||
|
|
||||||
export const SHORTCUT_KEYS = [
|
export const SHORTCUT_KEYS = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
label: 'NAVIGATE_DROPDOWN',
|
label: 'OPEN_CONVERSATION',
|
||||||
firstKey: 'Up',
|
displayKeys: [KEYS.ALT, 'J', KEYS.SLASH, KEYS.ALT, 'K'],
|
||||||
secondKey: 'Down',
|
keySet: ['Alt+KeyJ', 'Alt+KeyK'],
|
||||||
keySet: ['ArrowUp', 'ArrowDown'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
label: 'RESOLVE_CONVERSATION',
|
label: 'RESOLVE_AND_NEXT',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.WIN, KEYS.ALT, 'E'],
|
||||||
secondKey: 'E',
|
keySet: ['$mod+Alt+KeyE'],
|
||||||
keySet: ['Alt+KeyE'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
label: 'GO_TO_CONVERSATION_DASHBOARD',
|
label: 'NAVIGATE_DROPDOWN',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.UP, KEYS.DOWN],
|
||||||
secondKey: 'C',
|
keySet: ['ArrowUp', 'ArrowDown'],
|
||||||
keySet: ['Alt+KeyC'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
label: 'ADD_ATTACHMENT',
|
label: 'RESOLVE_CONVERSATION',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'E'],
|
||||||
secondKey: 'A',
|
keySet: ['Alt+KeyE'],
|
||||||
keySet: ['Alt+KeyA'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
label: 'GO_TO_CONTACTS_DASHBOARD',
|
label: 'GO_TO_CONVERSATION_DASHBOARD',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'C'],
|
||||||
secondKey: 'V',
|
keySet: ['Alt+KeyC'],
|
||||||
keySet: ['Alt+KeyV'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
label: 'TOGGLE_SIDEBAR',
|
label: 'ADD_ATTACHMENT',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.WIN, KEYS.ALT, 'A'],
|
||||||
secondKey: 'O',
|
keySet: ['$mod+Alt+KeyA'],
|
||||||
keySet: ['Alt+KeyO'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 7,
|
id: 7,
|
||||||
label: 'GO_TO_REPORTS_SIDEBAR',
|
label: 'GO_TO_CONTACTS_DASHBOARD',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'V'],
|
||||||
secondKey: 'R',
|
keySet: ['Alt+KeyV'],
|
||||||
keySet: ['Alt+KeyR'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
label: 'MOVE_TO_NEXT_TAB',
|
label: 'TOGGLE_SIDEBAR',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'O'],
|
||||||
secondKey: 'N',
|
keySet: ['Alt+KeyO'],
|
||||||
keySet: ['Alt+KeyN'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 9,
|
id: 9,
|
||||||
label: 'GO_TO_SETTINGS',
|
label: 'GO_TO_REPORTS_SIDEBAR',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'R'],
|
||||||
secondKey: 'S',
|
keySet: ['Alt+KeyR'],
|
||||||
keySet: ['Alt+KeyS'],
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
label: 'MOVE_TO_NEXT_TAB',
|
||||||
|
displayKeys: [KEYS.ALT, 'N'],
|
||||||
|
keySet: ['Alt+KeyN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 11,
|
id: 11,
|
||||||
label: 'SWITCH_TO_PRIVATE_NOTE',
|
label: 'GO_TO_SETTINGS',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'S'],
|
||||||
secondKey: 'P',
|
keySet: ['Alt+KeyS'],
|
||||||
keySet: ['Alt+KeyP'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 12,
|
id: 12,
|
||||||
label: 'SWITCH_TO_REPLY',
|
label: 'SWITCH_TO_PRIVATE_NOTE',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'P'],
|
||||||
secondKey: 'L',
|
keySet: ['Alt+KeyP'],
|
||||||
keySet: ['Alt+KeyL'],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 13,
|
id: 13,
|
||||||
|
label: 'SWITCH_TO_REPLY',
|
||||||
|
displayKeys: [KEYS.ALT, 'L'],
|
||||||
|
keySet: ['Alt+KeyL'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 14,
|
||||||
label: 'TOGGLE_SNOOZE_DROPDOWN',
|
label: 'TOGGLE_SNOOZE_DROPDOWN',
|
||||||
firstKey: 'Alt / ⌥',
|
displayKeys: [KEYS.ALT, 'M'],
|
||||||
secondKey: 'M',
|
|
||||||
keySet: ['Alt+KeyM'],
|
keySet: ['Alt+KeyM'],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useFontSize } from '../useFontSize';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('dashboard/composables/useUISettings');
|
||||||
|
vi.mock('dashboard/composables', () => ({
|
||||||
|
useAlert: vi.fn(message => message),
|
||||||
|
}));
|
||||||
|
vi.mock('vue-i18n');
|
||||||
|
|
||||||
|
// Mock requestAnimationFrame
|
||||||
|
global.requestAnimationFrame = vi.fn(cb => cb());
|
||||||
|
|
||||||
|
describe('useFontSize', () => {
|
||||||
|
const mockUISettings = ref({
|
||||||
|
font_size: '16px',
|
||||||
|
});
|
||||||
|
const mockUpdateUISettings = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockTranslate = vi.fn(key => key);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup mocks
|
||||||
|
useUISettings.mockReturnValue({
|
||||||
|
uiSettings: mockUISettings,
|
||||||
|
updateUISettings: mockUpdateUISettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
useI18n.mockReturnValue({
|
||||||
|
t: mockTranslate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset DOM state
|
||||||
|
document.documentElement.style.removeProperty('font-size');
|
||||||
|
|
||||||
|
// Reset mockUISettings to default
|
||||||
|
mockUISettings.value = { font_size: '16px' };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fontSizeOptions with correct structure', () => {
|
||||||
|
const { fontSizeOptions } = useFontSize();
|
||||||
|
expect(fontSizeOptions).toHaveLength(6);
|
||||||
|
expect(fontSizeOptions[0]).toHaveProperty('value');
|
||||||
|
expect(fontSizeOptions[0]).toHaveProperty('label');
|
||||||
|
|
||||||
|
// Check specific options
|
||||||
|
expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({
|
||||||
|
value: '16px',
|
||||||
|
label:
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({
|
||||||
|
value: '14px',
|
||||||
|
label:
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
|
||||||
|
value: '22px',
|
||||||
|
label:
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns currentFontSize from UI settings', () => {
|
||||||
|
const { currentFontSize } = useFontSize();
|
||||||
|
expect(currentFontSize.value).toBe('16px');
|
||||||
|
|
||||||
|
mockUISettings.value.font_size = '18px';
|
||||||
|
expect(currentFontSize.value).toBe('18px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies font size to document root correctly based on pixel values', () => {
|
||||||
|
const { applyFontSize } = useFontSize();
|
||||||
|
|
||||||
|
applyFontSize('18px');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('18px');
|
||||||
|
|
||||||
|
applyFontSize('14px');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('14px');
|
||||||
|
|
||||||
|
applyFontSize('22px');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('22px');
|
||||||
|
|
||||||
|
applyFontSize('16px');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('16px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates UI settings and applies font size', async () => {
|
||||||
|
const { updateFontSize } = useFontSize();
|
||||||
|
|
||||||
|
await updateFontSize('20px');
|
||||||
|
|
||||||
|
expect(mockUpdateUISettings).toHaveBeenCalledWith({ font_size: '20px' });
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('20px');
|
||||||
|
expect(useAlert).toHaveBeenCalledWith(
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error alert when update fails', async () => {
|
||||||
|
mockUpdateUISettings.mockRejectedValueOnce(new Error('Update failed'));
|
||||||
|
|
||||||
|
const { updateFontSize } = useFontSize();
|
||||||
|
await updateFontSize('20px');
|
||||||
|
|
||||||
|
expect(useAlert).toHaveBeenCalledWith(
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown font size values gracefully', () => {
|
||||||
|
const { applyFontSize } = useFontSize();
|
||||||
|
|
||||||
|
// Should not throw an error and should apply the default font size
|
||||||
|
applyFontSize('unknown-size');
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('16px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('watches for UI settings changes and applies font size', async () => {
|
||||||
|
useFontSize();
|
||||||
|
|
||||||
|
// Initial font size should now be 16px instead of empty
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('16px');
|
||||||
|
|
||||||
|
// Update UI settings
|
||||||
|
mockUISettings.value = { font_size: '18px' };
|
||||||
|
|
||||||
|
// Wait for next tick to let watchers fire
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(document.documentElement.style.fontSize).toBe('18px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('translates font size option labels correctly', () => {
|
||||||
|
// Set up specific translation mapping
|
||||||
|
mockTranslate.mockImplementation(key => {
|
||||||
|
const translations = {
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER':
|
||||||
|
'Smaller',
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
|
||||||
|
'Default',
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
|
||||||
|
'Extra Large',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fontSizeOptions } = useFontSize();
|
||||||
|
|
||||||
|
// Check that translation is applied
|
||||||
|
expect(fontSizeOptions.find(option => option.value === '14px').label).toBe(
|
||||||
|
'Smaller'
|
||||||
|
);
|
||||||
|
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
|
||||||
|
'Default'
|
||||||
|
);
|
||||||
|
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
|
||||||
|
'Extra Large'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify translation function was called with correct keys
|
||||||
|
expect(mockTranslate).toHaveBeenCalledWith(
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER'
|
||||||
|
);
|
||||||
|
expect(mockTranslate).toHaveBeenCalledWith(
|
||||||
|
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* @file useFontSize.js
|
||||||
|
* @description A composable for managing font size settings throughout the application.
|
||||||
|
* This handles font size selection, application to the DOM, and persistence in user settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { computed, watch } from 'vue';
|
||||||
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font size options with their pixel values
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
const FONT_SIZE_OPTIONS = {
|
||||||
|
SMALLER: '14px',
|
||||||
|
SMALL: '15px',
|
||||||
|
DEFAULT: '16px',
|
||||||
|
LARGE: '18px',
|
||||||
|
LARGER: '20px',
|
||||||
|
EXTRA_LARGE: '22px',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of font size option keys
|
||||||
|
* @type {Array<string>}
|
||||||
|
*/
|
||||||
|
const FONT_SIZE_NAMES = Object.keys(FONT_SIZE_OPTIONS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get font size label translation key
|
||||||
|
*
|
||||||
|
* @param {string} name - Font size name
|
||||||
|
* @returns {string} Translation key
|
||||||
|
*/
|
||||||
|
const getFontSizeLabelKey = name =>
|
||||||
|
`PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.${name}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create font size option object
|
||||||
|
*
|
||||||
|
* @param {Function} t - Translation function
|
||||||
|
* @param {string} name - Font size name
|
||||||
|
* @returns {Object} Font size option with value and label
|
||||||
|
*/
|
||||||
|
const createFontSizeOption = (t, name) => ({
|
||||||
|
value: FONT_SIZE_OPTIONS[name],
|
||||||
|
label: t(getFontSizeLabelKey(name)),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply font size value to document root
|
||||||
|
*
|
||||||
|
* @param {string} pixelValue - Font size value in pixels
|
||||||
|
*/
|
||||||
|
const applyFontSizeToDOM = pixelValue => {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'font-size',
|
||||||
|
pixelValue ?? FONT_SIZE_OPTIONS.DEFAULT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font size management composable
|
||||||
|
*
|
||||||
|
* @returns {Object} Font size utilities and state
|
||||||
|
* @property {Array} fontSizeOptions - Array of font size options for select components
|
||||||
|
* @property {import('vue').ComputedRef<string>} currentFontSize - Current font size from UI settings
|
||||||
|
* @property {Function} applyFontSize - Function to apply font size to document
|
||||||
|
* @property {Function} updateFontSize - Function to update font size in settings with alert feedback
|
||||||
|
*/
|
||||||
|
export const useFontSize = () => {
|
||||||
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font size options for select dropdown
|
||||||
|
* @type {Array<{value: string, label: string}>}
|
||||||
|
*/
|
||||||
|
const fontSizeOptions = FONT_SIZE_NAMES.map(name =>
|
||||||
|
createFontSizeOption(t, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current font size from UI settings
|
||||||
|
* @type {import('vue').ComputedRef<string>}
|
||||||
|
*/
|
||||||
|
const currentFontSize = computed(
|
||||||
|
() => uiSettings.value.font_size || FONT_SIZE_OPTIONS.DEFAULT
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply font size to document root
|
||||||
|
* @param {string} pixelValue - Font size in pixels (e.g., '16px')
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const applyFontSize = pixelValue => {
|
||||||
|
// Use requestAnimationFrame for better performance
|
||||||
|
requestAnimationFrame(() => applyFontSizeToDOM(pixelValue));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update font size in settings and apply to document
|
||||||
|
* Shows success/error alerts
|
||||||
|
* @param {string} pixelValue - Font size in pixels (e.g., '16px')
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const updateFontSize = async pixelValue => {
|
||||||
|
try {
|
||||||
|
await updateUISettings({ font_size: pixelValue });
|
||||||
|
applyFontSize(pixelValue);
|
||||||
|
useAlert(
|
||||||
|
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS')
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes to the font size in UI settings
|
||||||
|
watch(
|
||||||
|
() => uiSettings.value.font_size,
|
||||||
|
newSize => {
|
||||||
|
applyFontSize(newSize);
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fontSizeOptions,
|
||||||
|
currentFontSize,
|
||||||
|
applyFontSize,
|
||||||
|
updateFontSize,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useFontSize;
|
||||||
@@ -124,3 +124,9 @@ export const SLA_EVENTS = Object.freeze({
|
|||||||
UPDATE: 'Updated an SLA',
|
UPDATE: 'Updated an SLA',
|
||||||
DELETED: 'Deleted an SLA',
|
DELETED: 'Deleted an SLA',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const LINEAR_EVENTS = Object.freeze({
|
||||||
|
CREATE_ISSUE: 'Created a linear issue',
|
||||||
|
LINK_ISSUE: 'Linked a linear issue',
|
||||||
|
UNLINK_ISSUE: 'Unlinked a linear issue',
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
const { websocketURL = '' } = window.chatwootConfig || {};
|
const { websocketURL = '' } = window.chatwootConfig || {};
|
||||||
super(app, pubsubToken, websocketURL);
|
super(app, pubsubToken, websocketURL);
|
||||||
this.CancelTyping = [];
|
this.CancelTyping = [];
|
||||||
|
this.conversationEventTimestamps = new Map();
|
||||||
this.events = {
|
this.events = {
|
||||||
'message.created': this.onMessageCreated,
|
'message.created': this.onMessageCreated,
|
||||||
'message.updated': this.onMessageUpdated,
|
'message.updated': this.onMessageUpdated,
|
||||||
@@ -33,6 +34,23 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a conversation event is out of sequence by comparing timestamps
|
||||||
|
* @param {number} conversationId - The ID of the conversation to check
|
||||||
|
* @param {number} eventTimestamp - The timestamp of the current event
|
||||||
|
* @returns {boolean} Returns true if event is out of sequence, false otherwise
|
||||||
|
*/
|
||||||
|
eventOutOfSequence(conversationId, eventTimestamp) {
|
||||||
|
if (!eventTimestamp) return false;
|
||||||
|
const lastTimestamp = this.conversationEventTimestamps.get(conversationId);
|
||||||
|
if (!lastTimestamp || eventTimestamp > lastTimestamp) {
|
||||||
|
this.conversationEventTimestamps.set(conversationId, eventTimestamp);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
onReconnect = () => {
|
onReconnect = () => {
|
||||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
|
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
|
||||||
@@ -68,8 +86,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onAssigneeChanged = payload => {
|
onAssigneeChanged = (payload, eventTimestamp) => {
|
||||||
const { id } = payload;
|
const { id } = payload;
|
||||||
|
if (this.eventOutOfSequence(id, eventTimestamp)) return;
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
this.app.$store.dispatch('updateConversation', payload);
|
this.app.$store.dispatch('updateConversation', payload);
|
||||||
}
|
}
|
||||||
@@ -81,7 +101,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
this.fetchConversationStats();
|
this.fetchConversationStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
onConversationRead = data => {
|
onConversationRead = (data, eventTimestamp) => {
|
||||||
|
if (this.eventOutOfSequence(data.id, eventTimestamp)) return;
|
||||||
this.app.$store.dispatch('updateConversation', data);
|
this.app.$store.dispatch('updateConversation', data);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -104,12 +125,14 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
onReload = () => window.location.reload();
|
onReload = () => window.location.reload();
|
||||||
|
|
||||||
onStatusChange = data => {
|
onStatusChange = (data, eventTimestamp) => {
|
||||||
|
if (this.eventOutOfSequence(data.id, eventTimestamp)) return;
|
||||||
this.app.$store.dispatch('updateConversation', data);
|
this.app.$store.dispatch('updateConversation', data);
|
||||||
this.fetchConversationStats();
|
this.fetchConversationStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
onConversationUpdated = data => {
|
onConversationUpdated = (data, eventTimestamp) => {
|
||||||
|
if (this.eventOutOfSequence(data.id, eventTimestamp)) return;
|
||||||
this.app.$store.dispatch('updateConversation', data);
|
this.app.$store.dispatch('updateConversation', data);
|
||||||
this.fetchConversationStats();
|
this.fetchConversationStats();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ export const setColorTheme = isOSOnDarkMode => {
|
|||||||
selectedColorScheme === 'dark'
|
selectedColorScheme === 'dark'
|
||||||
) {
|
) {
|
||||||
document.body.classList.add('dark');
|
document.body.classList.add('dark');
|
||||||
document.documentElement.setAttribute('style', 'color-scheme: dark;');
|
document.documentElement.style.setProperty('color-scheme', 'dark');
|
||||||
} else {
|
} else {
|
||||||
document.body.classList.remove('dark');
|
document.body.classList.remove('dark');
|
||||||
document.documentElement.setAttribute('style', 'color-scheme: light;');
|
document.documentElement.style.setProperty('color-scheme', 'light');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -266,7 +266,7 @@
|
|||||||
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
|
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
|
||||||
},
|
},
|
||||||
"SEARCH": {
|
"SEARCH": {
|
||||||
"ERROR": "ERROR_MESSAGE"
|
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||||
},
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"SUBMIT": " Merge contacts",
|
"SUBMIT": " Merge contacts",
|
||||||
@@ -288,6 +288,8 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search...",
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
"MESSAGE_BUTTON": "Message",
|
"MESSAGE_BUTTON": "Message",
|
||||||
"SEND_MESSAGE": "Send message",
|
"SEND_MESSAGE": "Send message",
|
||||||
|
"BLOCK_CONTACT": "Block contact",
|
||||||
|
"UNBLOCK_CONTACT": "Unblock contact",
|
||||||
"BREADCRUMB": {
|
"BREADCRUMB": {
|
||||||
"CONTACTS": "Contacts"
|
"CONTACTS": "Contacts"
|
||||||
},
|
},
|
||||||
@@ -302,6 +304,10 @@
|
|||||||
"SUCCESS_MESSAGE": "Contact saved successfully",
|
"SUCCESS_MESSAGE": "Contact saved successfully",
|
||||||
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
||||||
},
|
},
|
||||||
|
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
|
||||||
|
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
|
||||||
|
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
|
||||||
|
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
|
||||||
"IMPORT_CONTACT": {
|
"IMPORT_CONTACT": {
|
||||||
"TITLE": "Import contacts",
|
"TITLE": "Import contacts",
|
||||||
"DESCRIPTION": "Import contacts through a CSV file.",
|
"DESCRIPTION": "Import contacts through a CSV file.",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"PENDING": "Mark as pending",
|
"PENDING": "Mark as pending",
|
||||||
"RESOLVED": "Mark as resolved",
|
"RESOLVED": "Mark as resolved",
|
||||||
"MARK_AS_UNREAD": "Mark as unread",
|
"MARK_AS_UNREAD": "Mark as unread",
|
||||||
|
"MARK_AS_READ": "Mark as read",
|
||||||
"REOPEN": "Reopen conversation",
|
"REOPEN": "Reopen conversation",
|
||||||
"SNOOZE": {
|
"SNOOZE": {
|
||||||
"TITLE": "Snooze",
|
"TITLE": "Snooze",
|
||||||
|
|||||||
@@ -300,6 +300,12 @@
|
|||||||
"TITLE": "Unlink",
|
"TITLE": "Unlink",
|
||||||
"SUCCESS": "Issue unlinked successfully",
|
"SUCCESS": "Issue unlinked successfully",
|
||||||
"ERROR": "There was an error unlinking the issue, please try again"
|
"ERROR": "There was an error unlinking the issue, please try again"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"TITLE": "Are you sure you want to delete the integration?",
|
||||||
|
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||||
|
"CONFIRM": "Yes, delete",
|
||||||
|
"CANCEL": "Cancel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -310,7 +316,8 @@
|
|||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"USE": "Use this",
|
"USE": "Use this",
|
||||||
"RESET": "Reset"
|
"RESET": "Reset",
|
||||||
|
"SELECT_ASSISTANT": "Select Assistant"
|
||||||
},
|
},
|
||||||
"PAYWALL": {
|
"PAYWALL": {
|
||||||
"TITLE": "Upgrade to use Captain AI",
|
"TITLE": "Upgrade to use Captain AI",
|
||||||
@@ -433,6 +440,20 @@
|
|||||||
"DOCUMENTABLE": {
|
"DOCUMENTABLE": {
|
||||||
"CONVERSATION": "Conversation #{id}"
|
"CONVERSATION": "Conversation #{id}"
|
||||||
},
|
},
|
||||||
|
"SELECTED": "{count} selected",
|
||||||
|
"BULK_APPROVE_BUTTON": "Approve",
|
||||||
|
"BULK_DELETE_BUTTON": "Delete",
|
||||||
|
"BULK_APPROVE": {
|
||||||
|
"SUCCESS_MESSAGE": "FAQs approved successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
|
||||||
|
},
|
||||||
|
"BULK_DELETE": {
|
||||||
|
"TITLE": "Delete FAQs?",
|
||||||
|
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
|
||||||
|
"CONFIRM": "Yes, delete all",
|
||||||
|
"SUCCESS_MESSAGE": "FAQs deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
|
||||||
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Are you sure to delete the FAQ?",
|
"TITLE": "Are you sure to delete the FAQ?",
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -35,6 +35,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"INTERFACE_SECTION": {
|
||||||
|
"TITLE": "Interface",
|
||||||
|
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
|
||||||
|
"FONT_SIZE": {
|
||||||
|
"TITLE": "Font size",
|
||||||
|
"NOTE": "Adjust the text size across the dashboard based on your preference.",
|
||||||
|
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
|
||||||
|
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
|
||||||
|
"OPTIONS": {
|
||||||
|
"SMALLER": "Smaller",
|
||||||
|
"SMALL": "Small",
|
||||||
|
"DEFAULT": "Default",
|
||||||
|
"LARGE": "Large",
|
||||||
|
"LARGER": "Larger",
|
||||||
|
"EXTRA_LARGE": "Extra Large"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"MESSAGE_SIGNATURE_SECTION": {
|
"MESSAGE_SIGNATURE_SECTION": {
|
||||||
"TITLE": "Personal message signature",
|
"TITLE": "Personal message signature",
|
||||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||||
@@ -389,11 +407,6 @@
|
|||||||
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
|
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
|
||||||
"SWITCH_TO_REPLY": "Switch to Reply",
|
"SWITCH_TO_REPLY": "Switch to Reply",
|
||||||
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
|
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
|
||||||
},
|
|
||||||
"KEYS": {
|
|
||||||
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
|
|
||||||
"ALT_OR_OPTION_KEY": "Alt / ⌥",
|
|
||||||
"FORWARD_SLASH_KEY": "/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@
|
|||||||
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال من <strong>{primaryContactName}</strong> إلى <strong>{parentContactName}</strong>."
|
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال من <strong>{primaryContactName}</strong> إلى <strong>{parentContactName}</strong>."
|
||||||
},
|
},
|
||||||
"SEARCH": {
|
"SEARCH": {
|
||||||
"ERROR": "رسالة_خطأ"
|
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||||
},
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"SUBMIT": " دمج جهات الاتصال",
|
"SUBMIT": " دمج جهات الاتصال",
|
||||||
@@ -288,6 +288,8 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search...",
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
"MESSAGE_BUTTON": "رسالة",
|
"MESSAGE_BUTTON": "رسالة",
|
||||||
"SEND_MESSAGE": "إرسال الرسالة",
|
"SEND_MESSAGE": "إرسال الرسالة",
|
||||||
|
"BLOCK_CONTACT": "Block contact",
|
||||||
|
"UNBLOCK_CONTACT": "Unblock contact",
|
||||||
"BREADCRUMB": {
|
"BREADCRUMB": {
|
||||||
"CONTACTS": "جهات الاتصال"
|
"CONTACTS": "جهات الاتصال"
|
||||||
},
|
},
|
||||||
@@ -302,6 +304,10 @@
|
|||||||
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
|
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
|
||||||
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
||||||
},
|
},
|
||||||
|
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
|
||||||
|
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
|
||||||
|
"UNBLOCK_SUCCESS_MESSAGE": "تم إلغاء حجب جهة الاتصال بنجاح",
|
||||||
|
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
|
||||||
"IMPORT_CONTACT": {
|
"IMPORT_CONTACT": {
|
||||||
"TITLE": "Import contacts",
|
"TITLE": "Import contacts",
|
||||||
"DESCRIPTION": "استيراد جهات الاتصال من خلال ملف CSV.",
|
"DESCRIPTION": "استيراد جهات الاتصال من خلال ملف CSV.",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"PENDING": "تحديد كمعلق",
|
"PENDING": "تحديد كمعلق",
|
||||||
"RESOLVED": "تحديد كمحلولة",
|
"RESOLVED": "تحديد كمحلولة",
|
||||||
"MARK_AS_UNREAD": "وضع علامة كغير مقروء",
|
"MARK_AS_UNREAD": "وضع علامة كغير مقروء",
|
||||||
|
"MARK_AS_READ": "تحديد كمقروء",
|
||||||
"REOPEN": "إعادة فتح المحادثة",
|
"REOPEN": "إعادة فتح المحادثة",
|
||||||
"SNOOZE": {
|
"SNOOZE": {
|
||||||
"TITLE": "تأجيل",
|
"TITLE": "تأجيل",
|
||||||
|
|||||||
@@ -300,6 +300,12 @@
|
|||||||
"TITLE": "إلغاء الربط",
|
"TITLE": "إلغاء الربط",
|
||||||
"SUCCESS": "تم إلغاء ربط المشكلة بنجاح",
|
"SUCCESS": "تم إلغاء ربط المشكلة بنجاح",
|
||||||
"ERROR": "حدث خطأ أثناء إلغاء ربط المشكلة، الرجاء المحاولة مرة أخرى"
|
"ERROR": "حدث خطأ أثناء إلغاء ربط المشكلة، الرجاء المحاولة مرة أخرى"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"TITLE": "Are you sure you want to delete the integration?",
|
||||||
|
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||||
|
"CONFIRM": "نعم، احذف",
|
||||||
|
"CANCEL": "إلغاء"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -310,7 +316,8 @@
|
|||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
"YOU": "أنت",
|
"YOU": "أنت",
|
||||||
"USE": "Use this",
|
"USE": "Use this",
|
||||||
"RESET": "Reset"
|
"RESET": "Reset",
|
||||||
|
"SELECT_ASSISTANT": "Select Assistant"
|
||||||
},
|
},
|
||||||
"PAYWALL": {
|
"PAYWALL": {
|
||||||
"TITLE": "Upgrade to use Captain AI",
|
"TITLE": "Upgrade to use Captain AI",
|
||||||
@@ -433,6 +440,20 @@
|
|||||||
"DOCUMENTABLE": {
|
"DOCUMENTABLE": {
|
||||||
"CONVERSATION": "Conversation #{id}"
|
"CONVERSATION": "Conversation #{id}"
|
||||||
},
|
},
|
||||||
|
"SELECTED": "{count} selected",
|
||||||
|
"BULK_APPROVE_BUTTON": "Approve",
|
||||||
|
"BULK_DELETE_BUTTON": "حذف",
|
||||||
|
"BULK_APPROVE": {
|
||||||
|
"SUCCESS_MESSAGE": "FAQs approved successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
|
||||||
|
},
|
||||||
|
"BULK_DELETE": {
|
||||||
|
"TITLE": "Delete FAQs?",
|
||||||
|
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
|
||||||
|
"CONFIRM": "Yes, delete all",
|
||||||
|
"SUCCESS_MESSAGE": "FAQs deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
|
||||||
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Are you sure to delete the FAQ?",
|
"TITLE": "Are you sure to delete the FAQ?",
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -35,6 +35,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"INTERFACE_SECTION": {
|
||||||
|
"TITLE": "Interface",
|
||||||
|
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
|
||||||
|
"FONT_SIZE": {
|
||||||
|
"TITLE": "Font size",
|
||||||
|
"NOTE": "Adjust the text size across the dashboard based on your preference.",
|
||||||
|
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
|
||||||
|
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
|
||||||
|
"OPTIONS": {
|
||||||
|
"SMALLER": "Smaller",
|
||||||
|
"SMALL": "Small",
|
||||||
|
"DEFAULT": "افتراضي",
|
||||||
|
"LARGE": "Large",
|
||||||
|
"LARGER": "Larger",
|
||||||
|
"EXTRA_LARGE": "Extra Large"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"MESSAGE_SIGNATURE_SECTION": {
|
"MESSAGE_SIGNATURE_SECTION": {
|
||||||
"TITLE": "توقيع الرسالة الشخصية",
|
"TITLE": "توقيع الرسالة الشخصية",
|
||||||
"NOTE": "إنشاء توقيع رسالة فريدة تظهر في نهاية كل رسالة ترسلها من أي صندوق وارد. يمكنك أيضًا تضمين صورة داخلية، مدعومة في الدردشة المباشرة، والبريد الإلكتروني، وصناديق API الواردة.",
|
"NOTE": "إنشاء توقيع رسالة فريدة تظهر في نهاية كل رسالة ترسلها من أي صندوق وارد. يمكنك أيضًا تضمين صورة داخلية، مدعومة في الدردشة المباشرة، والبريد الإلكتروني، وصناديق API الواردة.",
|
||||||
@@ -389,11 +407,6 @@
|
|||||||
"SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة",
|
"SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة",
|
||||||
"SWITCH_TO_REPLY": "التبديل إلى الرد",
|
"SWITCH_TO_REPLY": "التبديل إلى الرد",
|
||||||
"TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة"
|
"TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة"
|
||||||
},
|
|
||||||
"KEYS": {
|
|
||||||
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘\n",
|
|
||||||
"ALT_OR_OPTION_KEY": "Alt / ⌥",
|
|
||||||
"FORWARD_SLASH_KEY": "/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@
|
|||||||
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
|
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
|
||||||
},
|
},
|
||||||
"SEARCH": {
|
"SEARCH": {
|
||||||
"ERROR": "ERROR_MESSAGE"
|
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||||
},
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"SUBMIT": " Merge contacts",
|
"SUBMIT": " Merge contacts",
|
||||||
@@ -288,6 +288,8 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search...",
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
"MESSAGE_BUTTON": "Message",
|
"MESSAGE_BUTTON": "Message",
|
||||||
"SEND_MESSAGE": "Send message",
|
"SEND_MESSAGE": "Send message",
|
||||||
|
"BLOCK_CONTACT": "Block contact",
|
||||||
|
"UNBLOCK_CONTACT": "Unblock contact",
|
||||||
"BREADCRUMB": {
|
"BREADCRUMB": {
|
||||||
"CONTACTS": "Contacts"
|
"CONTACTS": "Contacts"
|
||||||
},
|
},
|
||||||
@@ -302,6 +304,10 @@
|
|||||||
"SUCCESS_MESSAGE": "Contact saved successfully",
|
"SUCCESS_MESSAGE": "Contact saved successfully",
|
||||||
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
||||||
},
|
},
|
||||||
|
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
|
||||||
|
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
|
||||||
|
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
|
||||||
|
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
|
||||||
"IMPORT_CONTACT": {
|
"IMPORT_CONTACT": {
|
||||||
"TITLE": "Import contacts",
|
"TITLE": "Import contacts",
|
||||||
"DESCRIPTION": "Import contacts through a CSV file.",
|
"DESCRIPTION": "Import contacts through a CSV file.",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"PENDING": "Mark as pending",
|
"PENDING": "Mark as pending",
|
||||||
"RESOLVED": "Mark as resolved",
|
"RESOLVED": "Mark as resolved",
|
||||||
"MARK_AS_UNREAD": "Mark as unread",
|
"MARK_AS_UNREAD": "Mark as unread",
|
||||||
|
"MARK_AS_READ": "Mark as read",
|
||||||
"REOPEN": "Reopen conversation",
|
"REOPEN": "Reopen conversation",
|
||||||
"SNOOZE": {
|
"SNOOZE": {
|
||||||
"TITLE": "Snooze",
|
"TITLE": "Snooze",
|
||||||
|
|||||||
@@ -300,6 +300,12 @@
|
|||||||
"TITLE": "Unlink",
|
"TITLE": "Unlink",
|
||||||
"SUCCESS": "Issue unlinked successfully",
|
"SUCCESS": "Issue unlinked successfully",
|
||||||
"ERROR": "There was an error unlinking the issue, please try again"
|
"ERROR": "There was an error unlinking the issue, please try again"
|
||||||
|
},
|
||||||
|
"DELETE": {
|
||||||
|
"TITLE": "Are you sure you want to delete the integration?",
|
||||||
|
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||||
|
"CONFIRM": "Yes, delete",
|
||||||
|
"CANCEL": "Cancel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -310,7 +316,8 @@
|
|||||||
"LOADER": "Captain is thinking",
|
"LOADER": "Captain is thinking",
|
||||||
"YOU": "You",
|
"YOU": "You",
|
||||||
"USE": "Use this",
|
"USE": "Use this",
|
||||||
"RESET": "Reset"
|
"RESET": "Reset",
|
||||||
|
"SELECT_ASSISTANT": "Select Assistant"
|
||||||
},
|
},
|
||||||
"PAYWALL": {
|
"PAYWALL": {
|
||||||
"TITLE": "Upgrade to use Captain AI",
|
"TITLE": "Upgrade to use Captain AI",
|
||||||
@@ -433,6 +440,20 @@
|
|||||||
"DOCUMENTABLE": {
|
"DOCUMENTABLE": {
|
||||||
"CONVERSATION": "Conversation #{id}"
|
"CONVERSATION": "Conversation #{id}"
|
||||||
},
|
},
|
||||||
|
"SELECTED": "{count} selected",
|
||||||
|
"BULK_APPROVE_BUTTON": "Approve",
|
||||||
|
"BULK_DELETE_BUTTON": "Delete",
|
||||||
|
"BULK_APPROVE": {
|
||||||
|
"SUCCESS_MESSAGE": "FAQs approved successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
|
||||||
|
},
|
||||||
|
"BULK_DELETE": {
|
||||||
|
"TITLE": "Delete FAQs?",
|
||||||
|
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
|
||||||
|
"CONFIRM": "Yes, delete all",
|
||||||
|
"SUCCESS_MESSAGE": "FAQs deleted successfully",
|
||||||
|
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
|
||||||
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Are you sure to delete the FAQ?",
|
"TITLE": "Are you sure to delete the FAQ?",
|
||||||
"DESCRIPTION": "",
|
"DESCRIPTION": "",
|
||||||
|
|||||||
@@ -35,6 +35,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"INTERFACE_SECTION": {
|
||||||
|
"TITLE": "Interface",
|
||||||
|
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
|
||||||
|
"FONT_SIZE": {
|
||||||
|
"TITLE": "Font size",
|
||||||
|
"NOTE": "Adjust the text size across the dashboard based on your preference.",
|
||||||
|
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
|
||||||
|
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
|
||||||
|
"OPTIONS": {
|
||||||
|
"SMALLER": "Smaller",
|
||||||
|
"SMALL": "Small",
|
||||||
|
"DEFAULT": "Default",
|
||||||
|
"LARGE": "Large",
|
||||||
|
"LARGER": "Larger",
|
||||||
|
"EXTRA_LARGE": "Extra Large"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"MESSAGE_SIGNATURE_SECTION": {
|
"MESSAGE_SIGNATURE_SECTION": {
|
||||||
"TITLE": "Personal message signature",
|
"TITLE": "Personal message signature",
|
||||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||||
@@ -389,11 +407,6 @@
|
|||||||
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
|
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
|
||||||
"SWITCH_TO_REPLY": "Switch to Reply",
|
"SWITCH_TO_REPLY": "Switch to Reply",
|
||||||
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
|
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
|
||||||
},
|
|
||||||
"KEYS": {
|
|
||||||
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
|
|
||||||
"ALT_OR_OPTION_KEY": "Alt / ⌥",
|
|
||||||
"FORWARD_SLASH_KEY": "/"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,7 +266,7 @@
|
|||||||
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>{primaryContactName}</strong> ще бъдат копирани в <strong>{parentContactName}</strong>."
|
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>{primaryContactName}</strong> ще бъдат копирани в <strong>{parentContactName}</strong>."
|
||||||
},
|
},
|
||||||
"SEARCH": {
|
"SEARCH": {
|
||||||
"ERROR": "ГРЕШКА"
|
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||||
},
|
},
|
||||||
"FORM": {
|
"FORM": {
|
||||||
"SUBMIT": " Обединяване на контакти",
|
"SUBMIT": " Обединяване на контакти",
|
||||||
@@ -288,6 +288,8 @@
|
|||||||
"SEARCH_PLACEHOLDER": "Search...",
|
"SEARCH_PLACEHOLDER": "Search...",
|
||||||
"MESSAGE_BUTTON": "Съобщение",
|
"MESSAGE_BUTTON": "Съобщение",
|
||||||
"SEND_MESSAGE": "Изпрати съобщение",
|
"SEND_MESSAGE": "Изпрати съобщение",
|
||||||
|
"BLOCK_CONTACT": "Block contact",
|
||||||
|
"UNBLOCK_CONTACT": "Unblock contact",
|
||||||
"BREADCRUMB": {
|
"BREADCRUMB": {
|
||||||
"CONTACTS": "Контакти"
|
"CONTACTS": "Контакти"
|
||||||
},
|
},
|
||||||
@@ -302,6 +304,10 @@
|
|||||||
"SUCCESS_MESSAGE": "Успешно запазване на контакта",
|
"SUCCESS_MESSAGE": "Успешно запазване на контакта",
|
||||||
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
|
||||||
},
|
},
|
||||||
|
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
|
||||||
|
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
|
||||||
|
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
|
||||||
|
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
|
||||||
"IMPORT_CONTACT": {
|
"IMPORT_CONTACT": {
|
||||||
"TITLE": "Import contacts",
|
"TITLE": "Import contacts",
|
||||||
"DESCRIPTION": "Внасяне на контакти чрез CSV файл.",
|
"DESCRIPTION": "Внасяне на контакти чрез CSV файл.",
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
"PENDING": "Mark as pending",
|
"PENDING": "Mark as pending",
|
||||||
"RESOLVED": "Mark as resolved",
|
"RESOLVED": "Mark as resolved",
|
||||||
"MARK_AS_UNREAD": "Mark as unread",
|
"MARK_AS_UNREAD": "Mark as unread",
|
||||||
|
"MARK_AS_READ": "Mark as read",
|
||||||
"REOPEN": "Reopen conversation",
|
"REOPEN": "Reopen conversation",
|
||||||
"SNOOZE": {
|
"SNOOZE": {
|
||||||
"TITLE": "Snooze",
|
"TITLE": "Snooze",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user