Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
320e7ea246 | ||
|
|
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',
|
||||
'plugin:vue/vue3-recommended',
|
||||
'plugin:vitest-globals/recommended',
|
||||
// use recommended-legacy when upgrading the plugin to v4
|
||||
'plugin:@intlify/vue-i18n/recommended',
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
@@ -229,6 +231,18 @@ module.exports = {
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off'],
|
||||
'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: {
|
||||
browser: true,
|
||||
|
||||
@@ -16,7 +16,7 @@ on:
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
|
||||
@@ -73,6 +73,7 @@ test/cypress/videos/*
|
||||
|
||||
#ignore files under .vscode directory
|
||||
.vscode
|
||||
.cursor
|
||||
|
||||
# yalc for local testing
|
||||
.yalc
|
||||
|
||||
+1
-1
@@ -799,7 +799,7 @@ GEM
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (2.4.2)
|
||||
uniform_notifier (1.16.0)
|
||||
uri (0.13.0)
|
||||
uri (1.0.3)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
button,
|
||||
input[type="button"],
|
||||
input[type="reset"],
|
||||
input[type="submit"],
|
||||
.button {
|
||||
button:not(.reset-base),
|
||||
input[type='button']:not(.reset-base),
|
||||
input[type='reset']:not(.reset-base),
|
||||
input[type='submit']:not(.reset-base),
|
||||
.button:not(.reset-base) {
|
||||
appearance: none;
|
||||
background-color: $color-woot;
|
||||
border: 0;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
|
||||
.icon-container {
|
||||
margin-right: 2px;
|
||||
|
||||
}
|
||||
|
||||
.value-container {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
|
||||
before_action :fetch_hook, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def teams
|
||||
teams = linear_processor_service.teams
|
||||
@@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
def permitted_params
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||
end
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
|
||||
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
|
||||
@@ -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
|
||||
@@ -39,6 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
%w[AZURE_APP_ID AZURE_APP_SECRET]
|
||||
when 'email'
|
||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||
when 'linear'
|
||||
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
|
||||
else
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
||||
end
|
||||
|
||||
@@ -78,7 +78,11 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.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
|
||||
# across all pages of the admin dashboard.
|
||||
|
||||
@@ -94,7 +94,12 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
# COLLECTION_FILTERS = {
|
||||
# open: ->(resources) { resources.where(open: true) }
|
||||
# }.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
|
||||
# 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)
|
||||
current_filter = model_filters[query_hash['attribute_key']]
|
||||
|
||||
@@ -89,4 +89,18 @@ module FilterHelper
|
||||
operator = condition['query_operator'].upcase
|
||||
raise CustomExceptions::CustomFilter::InvalidQueryOperator.new({}) unless %w[AND OR].include?(operator)
|
||||
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
|
||||
@@ -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
|
||||
@@ -4,7 +4,6 @@ import AddAccountModal from '../dashboard/components/layout/sidebarComponents/Ad
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -14,6 +13,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useFontSize } from 'dashboard/composables/useFontSize';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
@@ -30,15 +30,21 @@ export default {
|
||||
UpdateBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
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() {
|
||||
return {
|
||||
@@ -138,7 +144,6 @@ export default {
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
<UpgradeBanner />
|
||||
</template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
|
||||
@@ -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) {
|
||||
return axios.post(`${this.url}/${conversationId}/copilot`, body);
|
||||
}
|
||||
|
||||
getInboxAssistant(conversationId) {
|
||||
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConversationApi();
|
||||
|
||||
@@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -141,11 +141,16 @@ code {
|
||||
@apply text-xs border-0;
|
||||
|
||||
&.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-string {
|
||||
@apply text-red-800 dark:text-red-400;
|
||||
}
|
||||
|
||||
.hljs-name,
|
||||
.hljs-tag {
|
||||
@apply text-n-slate-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
|
||||
<template>
|
||||
<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">
|
||||
<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">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
|
||||
</div>
|
||||
</header>
|
||||
<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" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ const handleSubmit = campaignDetails => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const handleClose = () => emit('close');
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
|
||||
|
||||
@@ -4,6 +4,10 @@ defineProps({
|
||||
type: String,
|
||||
default: 'col',
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-3 px-6 py-5"
|
||||
:class="
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
|
||||
"
|
||||
class="flex w-full gap-3 py-5"
|
||||
:class="[
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
|
||||
selectable ? 'px-10 py-6' : 'px-6',
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -65,8 +65,8 @@ const toggleBlock = () => {
|
||||
<div
|
||||
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">
|
||||
<div class="w-full mx-auto max-w-[650px]">
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@@ -98,8 +98,8 @@ const toggleBlock = () => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[650px]">
|
||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
@@ -107,7 +107,7 @@ const toggleBlock = () => {
|
||||
|
||||
<div
|
||||
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" />
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ const emit = defineEmits([
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<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">
|
||||
{{ headerTitle }}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||
"
|
||||
:show-clear-button="!hasActiveSegments"
|
||||
class="max-w-[960px] px-6"
|
||||
class="max-w-[60rem] px-6"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
|
||||
@@ -72,7 +72,7 @@ const openFilter = () => {
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<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
|
||||
v-if="
|
||||
(hasAppliedFilters || !isNotSegmentView) &&
|
||||
|
||||
@@ -22,7 +22,7 @@ defineProps({
|
||||
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
|
||||
>
|
||||
<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
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
|
||||
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
|
||||
<template>
|
||||
<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">
|
||||
<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
|
||||
v-if="showHeaderTitle"
|
||||
class="flex items-center justify-start h-20 gap-2"
|
||||
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
|
||||
</div>
|
||||
</header>
|
||||
<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" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+7
-7
@@ -52,7 +52,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<h3>
|
||||
@@ -75,7 +75,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<div class="flex justify-between w-full gap-4 py-2">
|
||||
<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(
|
||||
@@ -90,9 +90,9 @@ onMounted(() => {
|
||||
'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-class="max-h-[150px]"
|
||||
custom-text-area-class="max-h-[9.375rem]"
|
||||
auto-height
|
||||
min-height="3rem"
|
||||
/>
|
||||
@@ -108,12 +108,12 @@ onMounted(() => {
|
||||
:label="
|
||||
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 class="flex justify-between w-full gap-3 py-2">
|
||||
<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')
|
||||
@@ -126,7 +126,7 @@ onMounted(() => {
|
||||
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-[224px]"
|
||||
class="w-[14rem]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ const handleCategory = async formData => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
{{
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ defineExpose({ state, isSubmitDisabled });
|
||||
color="slate"
|
||||
size="sm"
|
||||
: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"
|
||||
/>
|
||||
<EmojiInput
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ const handleDeletePortal = () => {
|
||||
</div>
|
||||
<div
|
||||
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
|
||||
:active-portal="activePortal"
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
|
||||
|
||||
<template>
|
||||
<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
|
||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createNewContact,
|
||||
fetchContactableInboxes,
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
@@ -47,6 +48,7 @@ const currentUser = useMapGetter('getCurrentUser');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
const messageSignature = useMapGetter('getMessageSignature');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
const sendWithSignature = computed(() =>
|
||||
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
|
||||
@@ -104,7 +106,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
||||
isFetchingInboxes.value = true;
|
||||
try {
|
||||
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;
|
||||
} catch (error) {
|
||||
isFetchingInboxes.value = false;
|
||||
@@ -162,9 +169,12 @@ watch(
|
||||
() => {
|
||||
if (activeContact.value && props.contactId) {
|
||||
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 = {
|
||||
...activeContact.value,
|
||||
contactInboxes: processContactableInboxes(contactInboxes),
|
||||
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<WhatsAppOptions
|
||||
|
||||
+3
-3
@@ -47,10 +47,10 @@ const removeAttachment = id => {
|
||||
<div
|
||||
v-for="attachment in filteredImageAttachments"
|
||||
:key="attachment.id"
|
||||
class="relative group/image w-[72px] h-[72px]"
|
||||
class="relative group/image w-[4.5rem] h-[4.5rem]"
|
||||
>
|
||||
<img
|
||||
class="object-cover w-[72px] h-[72px] rounded-lg"
|
||||
class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
|
||||
:src="attachment.thumb"
|
||||
/>
|
||||
<Button
|
||||
@@ -69,7 +69,7 @@ const removeAttachment = id => {
|
||||
<div
|
||||
v-for="attachment in filteredNonImageAttachments"
|
||||
: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">
|
||||
{{ fileNameWithEllipsis(attachment.resource) }}
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
|
||||
<template>
|
||||
<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
|
||||
: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 = (
|
||||
attachedFiles,
|
||||
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', () => {
|
||||
it('prepares direct upload files', () => {
|
||||
const files = [{ blobSignedId: 'signed1' }];
|
||||
|
||||
@@ -99,45 +99,50 @@ const computedJustify = computed(() => {
|
||||
const STYLE_CONFIG = {
|
||||
colors: {
|
||||
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:
|
||||
'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',
|
||||
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-text hover:underline outline-transparent',
|
||||
ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-text hover:enabled:underline outline-transparent',
|
||||
},
|
||||
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:
|
||||
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
|
||||
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-ruby-9 hover:underline 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:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
|
||||
ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
|
||||
},
|
||||
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:
|
||||
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
|
||||
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:underline outline-transparent',
|
||||
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 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:enabled:bg-n-amber-9/10 outline-n-amber-9',
|
||||
link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
|
||||
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
slate: {
|
||||
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:
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
|
||||
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
|
||||
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 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:enabled:bg-n-slate-9/10',
|
||||
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
|
||||
ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
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:
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
|
||||
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:underline outline-transparent',
|
||||
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 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:enabled:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
|
||||
ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
|
||||
},
|
||||
},
|
||||
sizes: {
|
||||
@@ -171,7 +176,7 @@ const STYLE_CONFIG = {
|
||||
center: 'justify-center',
|
||||
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(() => {
|
||||
|
||||
@@ -68,7 +68,7 @@ const handlePageChange = event => {
|
||||
<template>
|
||||
<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">
|
||||
<div class="w-full max-w-[960px] mx-auto">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<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"
|
||||
>
|
||||
@@ -96,7 +96,7 @@ const handlePageChange = event => {
|
||||
</div>
|
||||
</header>
|
||||
<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" />
|
||||
<div
|
||||
v-if="isFetching"
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -46,14 +47,27 @@ const props = defineProps({
|
||||
type: Number,
|
||||
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 [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const statusAction = computed(() => {
|
||||
if (props.status === 'pending') {
|
||||
return [
|
||||
@@ -102,8 +116,17 @@ const handleDocumentableClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :class="{ 'rounded-md': compact }">
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<CardLayout
|
||||
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">
|
||||
{{ question }}
|
||||
</span>
|
||||
@@ -148,7 +171,7 @@ const handleDocumentableClick = () => {
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
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">
|
||||
{{ documentable.name }}
|
||||
</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>
|
||||
<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
|
||||
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 CopilotAgentMessage from './CopilotAgentMessage.vue';
|
||||
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
|
||||
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
|
||||
import Icon from '../icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -26,9 +27,17 @@ const props = defineProps({
|
||||
type: String,
|
||||
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'];
|
||||
|
||||
@@ -97,14 +106,18 @@ watch(
|
||||
|
||||
<CopilotLoader v-if="isCaptainTyping" />
|
||||
</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">
|
||||
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
@click="() => useSuggestion(prompt)"
|
||||
>
|
||||
@@ -112,7 +125,17 @@ watch(
|
||||
<Icon icon="i-lucide-chevron-right" />
|
||||
</button>
|
||||
</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
|
||||
v-if="messages.length"
|
||||
class="text-xs flex items-center gap-1 hover:underline"
|
||||
@@ -121,8 +144,8 @@ watch(
|
||||
<i class="i-lucide-refresh-ccw" />
|
||||
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
|
||||
</button>
|
||||
<CopilotInput class="mb-1 flex-1 w-full" @send="sendMessage" />
|
||||
</div>
|
||||
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
// 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>
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useOperators } from './operators';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -59,7 +62,11 @@ export function useContactFilterContext() {
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(contactAttributes.value, getOperatorTypes)
|
||||
buildAttributesFilterTypes(
|
||||
contactAttributes.value,
|
||||
getOperatorTypes,
|
||||
'contact'
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -67,8 +74,8 @@ export function useContactFilterContext() {
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: 'name',
|
||||
value: 'name',
|
||||
attributeKey: CONTACT_ATTRIBUTES.NAME,
|
||||
value: CONTACT_ATTRIBUTES.NAME,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.NAME'),
|
||||
inputType: 'plainText',
|
||||
@@ -77,8 +84,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'email',
|
||||
value: 'email',
|
||||
attributeKey: CONTACT_ATTRIBUTES.EMAIL,
|
||||
value: CONTACT_ATTRIBUTES.EMAIL,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.EMAIL'),
|
||||
inputType: 'plainText',
|
||||
@@ -87,8 +94,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'phone_number',
|
||||
value: 'phone_number',
|
||||
attributeKey: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||
value: CONTACT_ATTRIBUTES.PHONE_NUMBER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.PHONE_NUMBER'),
|
||||
inputType: 'plainText',
|
||||
@@ -97,8 +104,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'identifier',
|
||||
value: 'identifier',
|
||||
attributeKey: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||
value: CONTACT_ATTRIBUTES.IDENTIFIER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
@@ -107,8 +114,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeKey: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONTACT_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -118,8 +125,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'city',
|
||||
value: 'city',
|
||||
attributeKey: CONTACT_ATTRIBUTES.CITY,
|
||||
value: CONTACT_ATTRIBUTES.CITY,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CITY'),
|
||||
inputType: 'plainText',
|
||||
@@ -128,8 +135,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
@@ -138,8 +145,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeKey: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
value: CONTACT_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.LAST_ACTIVITY'),
|
||||
inputType: 'date',
|
||||
@@ -148,8 +155,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeKey: CONTACT_ATTRIBUTES.REFERER,
|
||||
value: CONTACT_ATTRIBUTES.REFERER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
@@ -158,8 +165,8 @@ export function useContactFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'blocked',
|
||||
value: 'blocked',
|
||||
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
value: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.BLOCKED'),
|
||||
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
|
||||
* @param {string} key - The attribute display type key
|
||||
@@ -20,24 +52,37 @@ export const getCustomAttributeInputType = key => {
|
||||
|
||||
/**
|
||||
* Builds filter types for custom attributes
|
||||
* This also removes any conflicting attributes
|
||||
* @param {Array} attributes - The attributes array
|
||||
* @param {Function} getOperatorTypes - Function to get operator types
|
||||
* @returns {Array} Array of filter types
|
||||
*/
|
||||
export const buildAttributesFilterTypes = (attributes, getOperatorTypes) => {
|
||||
return attributes.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',
|
||||
}));
|
||||
export const buildAttributesFilterTypes = (
|
||||
attributes,
|
||||
getOperatorTypes,
|
||||
filterModel = 'conversation'
|
||||
) => {
|
||||
const standardAttributes = Object.values(
|
||||
filterModel === 'conversation'
|
||||
? CONVERSATION_ATTRIBUTES
|
||||
: CONTACT_ATTRIBUTES
|
||||
);
|
||||
|
||||
return attributes
|
||||
.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 { useMapGetter } from 'dashboard/composables/store.js';
|
||||
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 languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
@@ -70,7 +73,11 @@ export function useConversationFilterContext() {
|
||||
* @type {import('vue').ComputedRef<FilterType[]>}
|
||||
*/
|
||||
const customFilterTypes = computed(() =>
|
||||
buildAttributesFilterTypes(conversationAttributes.value, getOperatorTypes)
|
||||
buildAttributesFilterTypes(
|
||||
conversationAttributes.value,
|
||||
getOperatorTypes,
|
||||
'conversation'
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -78,8 +85,8 @@ export function useConversationFilterContext() {
|
||||
*/
|
||||
const filterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: 'status',
|
||||
value: 'status',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.STATUS,
|
||||
value: CONVERSATION_ATTRIBUTES.STATUS,
|
||||
attributeName: t('FILTER.ATTRIBUTES.STATUS'),
|
||||
label: t('FILTER.ATTRIBUTES.STATUS'),
|
||||
inputType: 'multiSelect',
|
||||
@@ -94,8 +101,24 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'assignee_id',
|
||||
value: 'assignee_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.PRIORITY,
|
||||
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'),
|
||||
label: t('FILTER.ATTRIBUTES.ASSIGNEE_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -110,8 +133,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'inbox_id',
|
||||
value: 'inbox_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.INBOX_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.INBOX_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -126,8 +149,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'team_id',
|
||||
value: 'team_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.TEAM_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.TEAM_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -137,8 +160,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'display_id',
|
||||
value: 'display_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.DISPLAY_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||
label: t('FILTER.ATTRIBUTES.CONVERSATION_IDENTIFIER'),
|
||||
inputType: 'plainText',
|
||||
@@ -147,8 +170,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'campaign_id',
|
||||
value: 'campaign_id',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||
value: CONVERSATION_ATTRIBUTES.CAMPAIGN_ID,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.CAMPAIGN_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -161,8 +184,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'labels',
|
||||
value: 'labels',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.LABELS,
|
||||
value: CONVERSATION_ATTRIBUTES.LABELS,
|
||||
attributeName: t('FILTER.ATTRIBUTES.LABELS'),
|
||||
label: t('FILTER.ATTRIBUTES.LABELS'),
|
||||
inputType: 'multiSelect',
|
||||
@@ -185,8 +208,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'browser_language',
|
||||
value: 'browser_language',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||
value: CONVERSATION_ATTRIBUTES.BROWSER_LANGUAGE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||
label: t('FILTER.ATTRIBUTES.BROWSER_LANGUAGE'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -196,8 +219,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
value: 'country_code',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
@@ -207,8 +230,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
value: 'referer',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
attributeName: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||
label: t('FILTER.ATTRIBUTES.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
@@ -217,8 +240,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'created_at',
|
||||
value: 'created_at',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||
value: CONVERSATION_ATTRIBUTES.CREATED_AT,
|
||||
attributeName: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||
label: t('FILTER.ATTRIBUTES.CREATED_AT'),
|
||||
inputType: 'date',
|
||||
@@ -227,8 +250,8 @@ export function useConversationFilterContext() {
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: 'last_activity_at',
|
||||
value: 'last_activity_at',
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
value: CONVERSATION_ATTRIBUTES.LAST_ACTIVITY_AT,
|
||||
attributeName: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
||||
label: t('FILTER.ATTRIBUTES.LAST_ACTIVITY'),
|
||||
inputType: 'date',
|
||||
|
||||
@@ -63,7 +63,7 @@ const pageInfo = computed(() => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||
|
||||
@@ -185,7 +185,7 @@ watch(
|
||||
"
|
||||
trailing-icon
|
||||
: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"
|
||||
>
|
||||
<span
|
||||
|
||||
@@ -515,7 +515,7 @@ const menuItems = computed(() => {
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
|
||||
@@ -31,7 +31,7 @@ const shouldRenderComponent = computed(() => {
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
: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="{
|
||||
'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="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="
|
||||
modelValue
|
||||
? 'translate-x-2.5 bg-white'
|
||||
|
||||
@@ -353,10 +353,11 @@ function setFiltersFromUISettings() {
|
||||
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
|
||||
const { status, order_by: orderBy } = filterBy;
|
||||
activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
|
||||
activeSortBy.value =
|
||||
Object.keys(wootConstants.SORT_BY_TYPE).find(
|
||||
sortField => sortField === orderBy
|
||||
) || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
|
||||
activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
|
||||
orderBy
|
||||
)
|
||||
? orderBy
|
||||
: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
|
||||
}
|
||||
|
||||
function emitConversationLoaded() {
|
||||
|
||||
@@ -23,33 +23,34 @@ export default {
|
||||
|
||||
<template>
|
||||
<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="{
|
||||
'border-b border-solid border-slate-50 dark:border-slate-700/30':
|
||||
showBorder,
|
||||
'border-b border-solid border-n-weak/60 dark:border-n-weak': showBorder,
|
||||
}"
|
||||
>
|
||||
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12">
|
||||
<p
|
||||
v-if="title"
|
||||
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
|
||||
>
|
||||
{{ title }}
|
||||
</p>
|
||||
<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>
|
||||
</p>
|
||||
<p v-if="note">
|
||||
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]">
|
||||
<slot />
|
||||
<div class="grid grid-cols-1 lg:grid-cols-6 gap-6">
|
||||
<div class="col-span-2 xl:col-span-1">
|
||||
<p
|
||||
v-if="title"
|
||||
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
|
||||
>
|
||||
{{ title }}
|
||||
</p>
|
||||
<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>
|
||||
</p>
|
||||
<p v-if="note">
|
||||
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-span-4 lg:col-span-4 2xl:col-span-3">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
const ALWAYS_ON_ROUTES = [
|
||||
'agent_list',
|
||||
'settings_inbox_list',
|
||||
'billing_settings_index',
|
||||
];
|
||||
|
||||
const { accountId } = useAccount();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const routeName = computed(() => {
|
||||
return route.name || '';
|
||||
});
|
||||
const isOnChatwootCloud = computed(
|
||||
() => store.getters['globalConfig/isOnChatwootCloud']
|
||||
);
|
||||
const account = computed(() =>
|
||||
store.getters['accounts/getAccount'](accountId.value)
|
||||
);
|
||||
|
||||
const isTrialAccount = computed(() => {
|
||||
if (!account.value) return false;
|
||||
|
||||
const createdAt = new Date(account.value.created_at);
|
||||
const diffDays = differenceInDays(new Date(), createdAt);
|
||||
|
||||
return diffDays <= 15;
|
||||
});
|
||||
|
||||
const testLimit = ({ allowed, consumed }) => {
|
||||
return consumed > allowed;
|
||||
};
|
||||
|
||||
const isLimitExceeded = computed(() => {
|
||||
if (ALWAYS_ON_ROUTES.includes(routeName.value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!account.value) return false;
|
||||
|
||||
const { limits } = account.value;
|
||||
if (!limits) return false;
|
||||
|
||||
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
|
||||
return testLimit(conversation) || testLimit(nonWebInboxes);
|
||||
});
|
||||
|
||||
const shouldShowBanner = computed(() => {
|
||||
if (!isOnChatwootCloud.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isTrialAccount.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isLimitExceeded.value;
|
||||
});
|
||||
|
||||
const fetchLimits = () => store.dispatch('accounts/limits');
|
||||
|
||||
onMounted(() => {
|
||||
if (isOnChatwootCloud.value) {
|
||||
fetchLimits();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="shouldShowBanner">Limits exceeded</div>
|
||||
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -1,8 +1,11 @@
|
||||
<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 ConversationAPI from 'dashboard/api/inbox/conversation';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { ref } from 'vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
@@ -13,10 +16,44 @@ const props = defineProps({
|
||||
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 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 = () => {
|
||||
messages.value = [];
|
||||
@@ -42,7 +79,7 @@ const sendMessage = async message => {
|
||||
}))
|
||||
.slice(0, -1),
|
||||
message,
|
||||
assistant_id: 16,
|
||||
assistant_id: selectedAssistantId.value,
|
||||
}
|
||||
);
|
||||
messages.value.push({
|
||||
@@ -57,6 +94,17 @@ const sendMessage = async message => {
|
||||
isCaptainTyping.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.conversationId) {
|
||||
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
|
||||
selectedAssistantId.value = activeAssistant.value?.id;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,6 +113,9 @@ const sendMessage = async message => {
|
||||
:support-agent="currentUser"
|
||||
:is-captain-typing="isCaptainTyping"
|
||||
:conversation-inbox-type="conversationInboxType"
|
||||
:assistants="assistants"
|
||||
:active-assistant="activeAssistant"
|
||||
@set-assistant="setAssistant"
|
||||
@send-message="sendMessage"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
|
||||
@@ -23,17 +23,16 @@ export default {
|
||||
|
||||
<template>
|
||||
<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="{
|
||||
'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,
|
||||
}"
|
||||
>
|
||||
<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="{
|
||||
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700':
|
||||
active,
|
||||
'bg-woot-50 border-b border-solid border-n-blue-border': active,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
|
||||
|
||||
@@ -38,20 +38,20 @@ export default {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 19px;
|
||||
height: 1.188rem;
|
||||
position: relative;
|
||||
transition-duration: 200ms;
|
||||
transition-property: background-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 34px;
|
||||
width: 2.125rem;
|
||||
|
||||
&.active {
|
||||
background-color: var(--w-500);
|
||||
}
|
||||
|
||||
&.small {
|
||||
width: 22px;
|
||||
height: 14px;
|
||||
width: 1.375rem;
|
||||
height: 0.875rem;
|
||||
|
||||
span {
|
||||
height: var(--space-one);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, provide, onMounted, computed } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { ref, useTemplateRef, provide, computed, watch } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
index: {
|
||||
@@ -19,6 +19,12 @@ const props = defineProps({
|
||||
|
||||
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 activeIndex = computed({
|
||||
@@ -34,20 +40,16 @@ provide('updateActiveIndex', index => {
|
||||
});
|
||||
|
||||
const computeScrollWidth = () => {
|
||||
// TODO: use useElementSize from vueuse
|
||||
const tabElement = document.querySelector('.tabs');
|
||||
if (tabElement) {
|
||||
hasScroll.value = tabElement.scrollWidth > tabElement.clientWidth;
|
||||
if (tabsContainer.value && tabsList.value) {
|
||||
hasScroll.value = tabsList.value.scrollWidth > tabsList.value.clientWidth;
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollClick = direction => {
|
||||
// TODO: use useElementSize from vueuse
|
||||
const tabElement = document.querySelector('.tabs');
|
||||
if (tabElement) {
|
||||
let scrollPosition = tabElement.scrollLeft;
|
||||
if (tabsContainer.value && tabsList.value) {
|
||||
let scrollPosition = tabsList.value.scrollLeft;
|
||||
scrollPosition += direction === 'left' ? -100 : 100;
|
||||
tabElement.scrollTo({
|
||||
tabsList.value.scrollTo({
|
||||
top: 0,
|
||||
left: scrollPosition,
|
||||
behavior: 'smooth',
|
||||
@@ -55,14 +57,19 @@ const onScrollClick = direction => {
|
||||
}
|
||||
};
|
||||
|
||||
useEventListener(window, 'resize', computeScrollWidth);
|
||||
onMounted(() => {
|
||||
computeScrollWidth();
|
||||
});
|
||||
// Watch for changes in element sizes with immediate execution
|
||||
watch(
|
||||
[containerWidth, listWidth],
|
||||
() => {
|
||||
computeScrollWidth();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="tabsContainer"
|
||||
:class="{
|
||||
'tabs--container--with-border': border,
|
||||
'tabs--container--compact': isCompact,
|
||||
@@ -76,7 +83,7 @@ onMounted(() => {
|
||||
>
|
||||
<fluent-icon icon="chevron-left" :size="16" />
|
||||
</button>
|
||||
<ul :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
|
||||
<ul ref="tabsList" :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
|
||||
<slot />
|
||||
</ul>
|
||||
<button
|
||||
|
||||
@@ -72,26 +72,26 @@ export default {
|
||||
|
||||
&.active {
|
||||
h3 {
|
||||
@apply text-woot-500 dark:text-woot-500;
|
||||
@apply text-n-blue-text dark:text-n-blue-text;
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-woot-500 dark:bg-woot-500;
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
}
|
||||
|
||||
&.over {
|
||||
&::after {
|
||||
@apply bg-woot-500 dark:bg-woot-500;
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
|
||||
.step {
|
||||
@apply bg-woot-500 dark:bg-woot-500;
|
||||
@apply bg-n-brand dark:bg-n-brand;
|
||||
}
|
||||
|
||||
& + .item {
|
||||
&::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>
|
||||
.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 {
|
||||
@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) {
|
||||
return errorMessage
|
||||
? '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>
|
||||
|
||||
<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">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
|
||||
@@ -127,7 +127,7 @@ export default {
|
||||
const uploadRef = ref(false);
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyA': {
|
||||
'$mod+Alt+KeyA': {
|
||||
action: () => {
|
||||
// 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.
|
||||
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<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
|
||||
:mode="mode"
|
||||
class="mt-3"
|
||||
|
||||
@@ -411,7 +411,7 @@ export default {
|
||||
}
|
||||
|
||||
.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'] {
|
||||
@apply m-0 cursor-pointer;
|
||||
|
||||
@@ -53,7 +53,7 @@ const showCopilotTab = computed(() =>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<TabBar
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { reactive, computed, onMounted, ref } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import validations from './validations';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import SearchableDropdown from './SearchableDropdown.vue';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -45,7 +47,7 @@ const statusDesiredOrder = [
|
||||
];
|
||||
|
||||
const isCreating = ref(false);
|
||||
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
|
||||
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
|
||||
|
||||
const formState = reactive({
|
||||
title: '',
|
||||
@@ -188,6 +190,7 @@ const createIssue = async () => {
|
||||
const { id: issueId } = response.data;
|
||||
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
|
||||
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
@@ -209,7 +212,7 @@ onMounted(getTeams);
|
||||
v-model="formState.title"
|
||||
:class="{ error: v$.title.$error }"
|
||||
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')"
|
||||
: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') }}
|
||||
<textarea
|
||||
v-model="formState.description"
|
||||
:style="{ ...inputStyles, padding: '8px 12px' }"
|
||||
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
|
||||
rows="3"
|
||||
class="text-sm"
|
||||
:placeholder="
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
|
||||
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
|
||||
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -85,6 +87,7 @@ const linkIssue = async () => {
|
||||
searchQuery.value = '';
|
||||
issues.value = [];
|
||||
onClose();
|
||||
useTrack(LINEAR_EVENTS.LINK_ISSUE);
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useI18n } from 'vue-i18n';
|
||||
import LinearAPI from 'dashboard/api/integrations/linear';
|
||||
import CreateOrLinkIssue from './CreateOrLinkIssue.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';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -56,6 +58,7 @@ const unlinkIssue = async linkId => {
|
||||
try {
|
||||
isUnlinking.value = true;
|
||||
await LinearAPI.unlinkIssue(linkId);
|
||||
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
|
||||
linkedIssue.value = null;
|
||||
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,41 +1,34 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { SHORTCUT_KEYS } from './constants';
|
||||
import { SHORTCUT_KEYS, KEYS } from './constants';
|
||||
import {
|
||||
LAYOUT_QWERTZ,
|
||||
keysToModifyInQWERTZ,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import Hotkey from 'dashboard/components/base/Hotkey.vue';
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineProps({ show: Boolean });
|
||||
defineEmits(['close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const shortcutKeys = SHORTCUT_KEYS;
|
||||
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
|
||||
// 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 layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
||||
// https://github.com/chatwoot/chatwoot/issues/9492
|
||||
const needsShiftKey = keySet => {
|
||||
return (
|
||||
const needsShiftKey = computed(
|
||||
() => keySet =>
|
||||
currentLayout.value === LAYOUT_QWERTZ &&
|
||||
keySet.some(key => keysToModifyInQWERTZ.has(key))
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
currentLayout.value = await useDetectKeyboardLayout();
|
||||
@@ -55,80 +48,46 @@ onMounted(async () => {
|
||||
</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') }}
|
||||
{{ KEYS.WIN }}
|
||||
</Hotkey>
|
||||
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
|
||||
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
|
||||
{{ KEYS.SLASH }}
|
||||
</Hotkey>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-for="shortcutKey in shortcutKeys"
|
||||
:key="shortcutKey.id"
|
||||
v-for="shortcut in SHORTCUT_KEYS"
|
||||
:key="shortcut.id"
|
||||
class="flex justify-between items-center min-w-[25rem]"
|
||||
>
|
||||
<h5 class="text-sm text-slate-800 min-w-[36px] dark:text-slate-100">
|
||||
{{ title(shortcutKey) }}
|
||||
{{ title(shortcut) }}
|
||||
</h5>
|
||||
<div class="flex items-center gap-2 mb-1 ml-2">
|
||||
<Hotkey
|
||||
v-if="needsShiftKey(shortcutKey.keySet)"
|
||||
custom-class="min-h-[28px] min-w-[36px] key"
|
||||
>
|
||||
{{ 'Shift' }}
|
||||
</Hotkey>
|
||||
<Hotkey
|
||||
:class="{ 'min-w-[60px]': shortcutKey.firstKey !== 'Up' }"
|
||||
custom-class="min-h-[28px] normal-case key"
|
||||
>
|
||||
{{ shortcutKey.firstKey }}
|
||||
</Hotkey>
|
||||
<Hotkey
|
||||
:class="{ 'normal-case': shortcutKey.secondKey === 'Down' }"
|
||||
custom-class="min-h-[28px] min-w-[36px] key"
|
||||
>
|
||||
{{ shortcutKey.secondKey }}
|
||||
</Hotkey>
|
||||
<template v-if="needsShiftKey(shortcut.keySet)">
|
||||
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
|
||||
{{ KEYS.SHIFT }}
|
||||
</Hotkey>
|
||||
</template>
|
||||
|
||||
<template v-for="(key, index) in shortcut.displayKeys" :key="index">
|
||||
<template v-if="key !== KEYS.SLASH">
|
||||
<Hotkey
|
||||
custom-class="min-h-[28px] min-w-[36px] key normal-case"
|
||||
>
|
||||
{{ key }}
|
||||
</Hotkey>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
</template>
|
||||
</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 = [
|
||||
{
|
||||
id: 1,
|
||||
label: 'NAVIGATE_DROPDOWN',
|
||||
firstKey: 'Up',
|
||||
secondKey: 'Down',
|
||||
keySet: ['ArrowUp', 'ArrowDown'],
|
||||
label: 'OPEN_CONVERSATION',
|
||||
displayKeys: [KEYS.ALT, 'J', KEYS.SLASH, KEYS.ALT, 'K'],
|
||||
keySet: ['Alt+KeyJ', 'Alt+KeyK'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'RESOLVE_CONVERSATION',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'E',
|
||||
keySet: ['Alt+KeyE'],
|
||||
label: 'RESOLVE_AND_NEXT',
|
||||
displayKeys: [KEYS.WIN, KEYS.ALT, 'E'],
|
||||
keySet: ['$mod+Alt+KeyE'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'GO_TO_CONVERSATION_DASHBOARD',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'C',
|
||||
keySet: ['Alt+KeyC'],
|
||||
label: 'NAVIGATE_DROPDOWN',
|
||||
displayKeys: [KEYS.UP, KEYS.DOWN],
|
||||
keySet: ['ArrowUp', 'ArrowDown'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'ADD_ATTACHMENT',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'A',
|
||||
keySet: ['Alt+KeyA'],
|
||||
label: 'RESOLVE_CONVERSATION',
|
||||
displayKeys: [KEYS.ALT, 'E'],
|
||||
keySet: ['Alt+KeyE'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: 'GO_TO_CONTACTS_DASHBOARD',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'V',
|
||||
keySet: ['Alt+KeyV'],
|
||||
label: 'GO_TO_CONVERSATION_DASHBOARD',
|
||||
displayKeys: [KEYS.ALT, 'C'],
|
||||
keySet: ['Alt+KeyC'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label: 'TOGGLE_SIDEBAR',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'O',
|
||||
keySet: ['Alt+KeyO'],
|
||||
label: 'ADD_ATTACHMENT',
|
||||
displayKeys: [KEYS.WIN, KEYS.ALT, 'A'],
|
||||
keySet: ['$mod+Alt+KeyA'],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
label: 'GO_TO_REPORTS_SIDEBAR',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'R',
|
||||
keySet: ['Alt+KeyR'],
|
||||
label: 'GO_TO_CONTACTS_DASHBOARD',
|
||||
displayKeys: [KEYS.ALT, 'V'],
|
||||
keySet: ['Alt+KeyV'],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
label: 'MOVE_TO_NEXT_TAB',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'N',
|
||||
keySet: ['Alt+KeyN'],
|
||||
label: 'TOGGLE_SIDEBAR',
|
||||
displayKeys: [KEYS.ALT, 'O'],
|
||||
keySet: ['Alt+KeyO'],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
label: 'GO_TO_SETTINGS',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'S',
|
||||
keySet: ['Alt+KeyS'],
|
||||
label: 'GO_TO_REPORTS_SIDEBAR',
|
||||
displayKeys: [KEYS.ALT, 'R'],
|
||||
keySet: ['Alt+KeyR'],
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
label: 'MOVE_TO_NEXT_TAB',
|
||||
displayKeys: [KEYS.ALT, 'N'],
|
||||
keySet: ['Alt+KeyN'],
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
label: 'SWITCH_TO_PRIVATE_NOTE',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'P',
|
||||
keySet: ['Alt+KeyP'],
|
||||
label: 'GO_TO_SETTINGS',
|
||||
displayKeys: [KEYS.ALT, 'S'],
|
||||
keySet: ['Alt+KeyS'],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
label: 'SWITCH_TO_REPLY',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'L',
|
||||
keySet: ['Alt+KeyL'],
|
||||
label: 'SWITCH_TO_PRIVATE_NOTE',
|
||||
displayKeys: [KEYS.ALT, 'P'],
|
||||
keySet: ['Alt+KeyP'],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
label: 'SWITCH_TO_REPLY',
|
||||
displayKeys: [KEYS.ALT, 'L'],
|
||||
keySet: ['Alt+KeyL'],
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
label: 'TOGGLE_SNOOZE_DROPDOWN',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'M',
|
||||
displayKeys: [KEYS.ALT, 'M'],
|
||||
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',
|
||||
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,9 +9,9 @@ export const setColorTheme = isOSOnDarkMode => {
|
||||
selectedColorScheme === 'dark'
|
||||
) {
|
||||
document.body.classList.add('dark');
|
||||
document.documentElement.setAttribute('style', 'color-scheme: dark;');
|
||||
document.documentElement.style.setProperty('color-scheme', 'dark');
|
||||
} else {
|
||||
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>."
|
||||
},
|
||||
"SEARCH": {
|
||||
"ERROR": "ERROR_MESSAGE"
|
||||
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||
},
|
||||
"FORM": {
|
||||
"SUBMIT": " Merge contacts",
|
||||
@@ -288,6 +288,8 @@
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"SEND_MESSAGE": "Send message",
|
||||
"BLOCK_CONTACT": "Block contact",
|
||||
"UNBLOCK_CONTACT": "Unblock contact",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Contacts"
|
||||
},
|
||||
@@ -302,6 +304,10 @@
|
||||
"SUCCESS_MESSAGE": "Contact saved successfully",
|
||||
"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": {
|
||||
"TITLE": "Import contacts",
|
||||
"DESCRIPTION": "Import contacts through a CSV file.",
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"PENDING": "Mark as pending",
|
||||
"RESOLVED": "Mark as resolved",
|
||||
"MARK_AS_UNREAD": "Mark as unread",
|
||||
"MARK_AS_READ": "Mark as read",
|
||||
"REOPEN": "Reopen conversation",
|
||||
"SNOOZE": {
|
||||
"TITLE": "Snooze",
|
||||
|
||||
@@ -300,6 +300,12 @@
|
||||
"TITLE": "Unlink",
|
||||
"SUCCESS": "Issue unlinked successfully",
|
||||
"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",
|
||||
"YOU": "You",
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset"
|
||||
"RESET": "Reset",
|
||||
"SELECT_ASSISTANT": "Select Assistant"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
@@ -433,6 +440,20 @@
|
||||
"DOCUMENTABLE": {
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete the FAQ?",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -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 / ⌘",
|
||||
"ALT_OR_OPTION_KEY": "Alt / ⌥",
|
||||
"FORWARD_SLASH_KEY": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال من <strong>{primaryContactName}</strong> إلى <strong>{parentContactName}</strong>."
|
||||
},
|
||||
"SEARCH": {
|
||||
"ERROR": "رسالة_خطأ"
|
||||
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||
},
|
||||
"FORM": {
|
||||
"SUBMIT": " دمج جهات الاتصال",
|
||||
@@ -288,6 +288,8 @@
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "رسالة",
|
||||
"SEND_MESSAGE": "إرسال الرسالة",
|
||||
"BLOCK_CONTACT": "Block contact",
|
||||
"UNBLOCK_CONTACT": "Unblock contact",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "جهات الاتصال"
|
||||
},
|
||||
@@ -302,6 +304,10 @@
|
||||
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
|
||||
"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": {
|
||||
"TITLE": "Import contacts",
|
||||
"DESCRIPTION": "استيراد جهات الاتصال من خلال ملف CSV.",
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"PENDING": "تحديد كمعلق",
|
||||
"RESOLVED": "تحديد كمحلولة",
|
||||
"MARK_AS_UNREAD": "وضع علامة كغير مقروء",
|
||||
"MARK_AS_READ": "تحديد كمقروء",
|
||||
"REOPEN": "إعادة فتح المحادثة",
|
||||
"SNOOZE": {
|
||||
"TITLE": "تأجيل",
|
||||
|
||||
@@ -300,6 +300,12 @@
|
||||
"TITLE": "إلغاء الربط",
|
||||
"SUCCESS": "تم إلغاء ربط المشكلة بنجاح",
|
||||
"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",
|
||||
"YOU": "أنت",
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset"
|
||||
"RESET": "Reset",
|
||||
"SELECT_ASSISTANT": "Select Assistant"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
@@ -433,6 +440,20 @@
|
||||
"DOCUMENTABLE": {
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete the FAQ?",
|
||||
"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": {
|
||||
"TITLE": "توقيع الرسالة الشخصية",
|
||||
"NOTE": "إنشاء توقيع رسالة فريدة تظهر في نهاية كل رسالة ترسلها من أي صندوق وارد. يمكنك أيضًا تضمين صورة داخلية، مدعومة في الدردشة المباشرة، والبريد الإلكتروني، وصناديق API الواردة.",
|
||||
@@ -389,11 +407,6 @@
|
||||
"SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة",
|
||||
"SWITCH_TO_REPLY": "التبديل إلى الرد",
|
||||
"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>."
|
||||
},
|
||||
"SEARCH": {
|
||||
"ERROR": "ERROR_MESSAGE"
|
||||
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||
},
|
||||
"FORM": {
|
||||
"SUBMIT": " Merge contacts",
|
||||
@@ -288,6 +288,8 @@
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Message",
|
||||
"SEND_MESSAGE": "Send message",
|
||||
"BLOCK_CONTACT": "Block contact",
|
||||
"UNBLOCK_CONTACT": "Unblock contact",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Contacts"
|
||||
},
|
||||
@@ -302,6 +304,10 @@
|
||||
"SUCCESS_MESSAGE": "Contact saved successfully",
|
||||
"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": {
|
||||
"TITLE": "Import contacts",
|
||||
"DESCRIPTION": "Import contacts through a CSV file.",
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"PENDING": "Mark as pending",
|
||||
"RESOLVED": "Mark as resolved",
|
||||
"MARK_AS_UNREAD": "Mark as unread",
|
||||
"MARK_AS_READ": "Mark as read",
|
||||
"REOPEN": "Reopen conversation",
|
||||
"SNOOZE": {
|
||||
"TITLE": "Snooze",
|
||||
|
||||
@@ -300,6 +300,12 @@
|
||||
"TITLE": "Unlink",
|
||||
"SUCCESS": "Issue unlinked successfully",
|
||||
"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",
|
||||
"YOU": "You",
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset"
|
||||
"RESET": "Reset",
|
||||
"SELECT_ASSISTANT": "Select Assistant"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
@@ -433,6 +440,20 @@
|
||||
"DOCUMENTABLE": {
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete the FAQ?",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -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 / ⌘",
|
||||
"ALT_OR_OPTION_KEY": "Alt / ⌥",
|
||||
"FORWARD_SLASH_KEY": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>{primaryContactName}</strong> ще бъдат копирани в <strong>{parentContactName}</strong>."
|
||||
},
|
||||
"SEARCH": {
|
||||
"ERROR": "ГРЕШКА"
|
||||
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||
},
|
||||
"FORM": {
|
||||
"SUBMIT": " Обединяване на контакти",
|
||||
@@ -288,6 +288,8 @@
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Съобщение",
|
||||
"SEND_MESSAGE": "Изпрати съобщение",
|
||||
"BLOCK_CONTACT": "Block contact",
|
||||
"UNBLOCK_CONTACT": "Unblock contact",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Контакти"
|
||||
},
|
||||
@@ -302,6 +304,10 @@
|
||||
"SUCCESS_MESSAGE": "Успешно запазване на контакта",
|
||||
"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": {
|
||||
"TITLE": "Import contacts",
|
||||
"DESCRIPTION": "Внасяне на контакти чрез CSV файл.",
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"PENDING": "Mark as pending",
|
||||
"RESOLVED": "Mark as resolved",
|
||||
"MARK_AS_UNREAD": "Mark as unread",
|
||||
"MARK_AS_READ": "Mark as read",
|
||||
"REOPEN": "Reopen conversation",
|
||||
"SNOOZE": {
|
||||
"TITLE": "Snooze",
|
||||
|
||||
@@ -300,6 +300,12 @@
|
||||
"TITLE": "Unlink",
|
||||
"SUCCESS": "Issue unlinked successfully",
|
||||
"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": "Отмени"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,7 +316,8 @@
|
||||
"LOADER": "Captain is thinking",
|
||||
"YOU": "You",
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset"
|
||||
"RESET": "Reset",
|
||||
"SELECT_ASSISTANT": "Select Assistant"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
@@ -433,6 +440,20 @@
|
||||
"DOCUMENTABLE": {
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete the FAQ?",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -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 / ⌘",
|
||||
"ALT_OR_OPTION_KEY": "Alt / ⌥",
|
||||
"FORWARD_SLASH_KEY": "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"ATTRIBUTE_WARNING": "Les dades de contacte de <strong>{primaryContactName}</strong> es copiaran a <strong>{parentContactName}</strong>."
|
||||
},
|
||||
"SEARCH": {
|
||||
"ERROR": "ERROR_MESSAGE"
|
||||
"ERROR_MESSAGE": "Something went wrong. Please try again later."
|
||||
},
|
||||
"FORM": {
|
||||
"SUBMIT": " Fusiona contactes",
|
||||
@@ -288,6 +288,8 @@
|
||||
"SEARCH_PLACEHOLDER": "Search...",
|
||||
"MESSAGE_BUTTON": "Missatge",
|
||||
"SEND_MESSAGE": "Envia missatge",
|
||||
"BLOCK_CONTACT": "Block contact",
|
||||
"UNBLOCK_CONTACT": "Unblock contact",
|
||||
"BREADCRUMB": {
|
||||
"CONTACTS": "Contactes"
|
||||
},
|
||||
@@ -302,6 +304,10 @@
|
||||
"SUCCESS_MESSAGE": "Contacte guardat correctament",
|
||||
"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": "Aquest contacte s'ha desbloquejat correctament",
|
||||
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
|
||||
"IMPORT_CONTACT": {
|
||||
"TITLE": "Import contacts",
|
||||
"DESCRIPTION": "Importa contactes a través d'un fitxer CSV.",
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"PENDING": "Marca com a pendent",
|
||||
"RESOLVED": "Marca com a resolt",
|
||||
"MARK_AS_UNREAD": "Marca com a no llegit",
|
||||
"MARK_AS_READ": "Marca com a llegit",
|
||||
"REOPEN": "Torna a obrir la conversa",
|
||||
"SNOOZE": {
|
||||
"TITLE": "Posposat",
|
||||
|
||||
@@ -300,6 +300,12 @@
|
||||
"TITLE": "Desenllaça",
|
||||
"SUCCESS": "S'ha desenllaçat la issue correctament",
|
||||
"ERROR": "S'ha produït un error en desenllaçar la issue, torna-ho a provar"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the integration?",
|
||||
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||
"CONFIRM": "Sí, esborra",
|
||||
"CANCEL": "Cancel·la"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,7 +316,8 @@
|
||||
"LOADER": "Captain is thinking",
|
||||
"YOU": "Tu",
|
||||
"USE": "Use this",
|
||||
"RESET": "Reset"
|
||||
"RESET": "Reset",
|
||||
"SELECT_ASSISTANT": "Select Assistant"
|
||||
},
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to use Captain AI",
|
||||
@@ -433,6 +440,20 @@
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
},
|
||||
"SELECTED": "{count} selected",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Esborrar",
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete the FAQ?",
|
||||
"DESCRIPTION": "",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user