Merge branch 'release/4.0.3'

This commit is contained in:
Sojan 2025-02-27 21:05:35 -08:00
commit 8f39e62570
480 changed files with 6041 additions and 1651 deletions

View File

@ -155,6 +155,10 @@ TWITTER_ENVIRONMENT=
SLACK_CLIENT_ID= SLACK_CLIENT_ID=
SLACK_CLIENT_SECRET= SLACK_CLIENT_SECRET=
#Linear Integration
LINEAR_CLIENT_ID=
LINEAR_CLIENT_SECRET=
# Google OAuth # Google OAuth
GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_CLIENT_SECRET=

View File

@ -4,6 +4,8 @@ module.exports = {
'prettier', 'prettier',
'plugin:vue/vue3-recommended', 'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended', 'plugin:vitest-globals/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
], ],
overrides: [ overrides: [
{ {
@ -229,6 +231,18 @@ module.exports = {
'vue/singleline-html-element-content-newline': 'off', 'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'], 'import/extensions': ['off'],
'no-console': 'error', 'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
},
settings: {
'vue-i18n': {
localeDir: './app/javascript/*/i18n/**.json',
},
}, },
env: { env: {
browser: true, browser: true,

1
.gitignore vendored
View File

@ -73,6 +73,7 @@ test/cypress/videos/*
#ignore files under .vscode directory #ignore files under .vscode directory
.vscode .vscode
.cursor
# yalc for local testing # yalc for local testing
.yalc .yalc

View File

@ -94,7 +94,7 @@ gem 'twitty', '~> 0.1.5'
# facebook client # facebook client
gem 'koala' gem 'koala'
# slack client # slack client
gem 'slack-ruby-client', '~> 2.5.1' gem 'slack-ruby-client', '~> 2.5.2'
# for dialogflow integrations # for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0' gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'grpc' gem 'grpc'

View File

@ -747,7 +747,7 @@ GEM
json (>= 1.8, < 3) json (>= 1.8, < 3)
simplecov-html (~> 0.10.0) simplecov-html (~> 0.10.0)
simplecov-html (0.10.2) simplecov-html (0.10.2)
slack-ruby-client (2.5.1) slack-ruby-client (2.5.2)
faraday (>= 2.0) faraday (>= 2.0)
faraday-mashify faraday-mashify
faraday-multipart faraday-multipart
@ -954,7 +954,7 @@ DEPENDENCIES
sidekiq (>= 7.3.1) sidekiq (>= 7.3.1)
sidekiq-cron (>= 1.12.0) sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1) simplecov (= 0.17.1)
slack-ruby-client (~> 2.5.1) slack-ruby-client (~> 2.5.2)
spring spring
spring-watcher-listen spring-watcher-listen
squasher squasher

View File

@ -1,5 +1,11 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues] before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def teams def teams
teams = linear_processor_service.teams teams = linear_processor_service.teams
@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def permitted_params def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: []) params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end end
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end
end end

View File

@ -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

View File

@ -0,0 +1,70 @@
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
OAuth2::Client.new(
ENV.fetch('LINEAR_CLIENT_ID', nil),
ENV.fetch('LINEAR_CLIENT_SECRET', nil),
{
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

View File

@ -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 ||= ENV.fetch('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

View File

@ -14,6 +14,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper'; import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper'; import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { import {
registerSubscription, registerSubscription,
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
@ -37,8 +38,15 @@ export default {
const router = useRouter(); const router = useRouter();
const store = useStore(); const store = useStore();
const { accountId } = useAccount(); const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
return { router, store, currentAccountId: accountId }; return {
router,
store,
currentAccountId: accountId,
currentFontSize,
};
}, },
data() { data() {
return { return {

View File

@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainBulkActionsAPI extends ApiClient {
constructor() {
super('captain/bulk_actions', { accountScoped: true });
}
}
export default new CaptainBulkActionsAPI();

View File

@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
} }
input[type='file'] { input[type='file'] {
@apply bg-white dark:bg-slate-800 leading-[1.15] mb-4; @apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
} }
// Select // Select
@ -141,11 +141,16 @@ code {
@apply text-xs border-0; @apply text-xs border-0;
&.hljs { &.hljs {
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5; @apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
.hljs-number, .hljs-number,
.hljs-string { .hljs-string {
@apply text-red-800 dark:text-red-400; @apply text-red-800 dark:text-red-400;
} }
.hljs-name,
.hljs-tag {
@apply text-n-slate-11;
}
} }
} }

View File

@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0"> <header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2"> <div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12"> <span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0"> <main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4"> <div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" /> <slot name="default" />
</div> </div>
</main> </main>

View File

@ -40,7 +40,7 @@ const handleSubmit = campaignDetails => {
<template> <template>
<div <div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto" class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
> >
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50"> <h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }} {{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}

View File

@ -39,7 +39,7 @@ const handleClose = () => emit('close');
<template> <template>
<div <div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6" class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
> >
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50"> <h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }} {{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}

View File

@ -4,6 +4,10 @@ defineProps({
type: String, type: String,
default: 'col', default: 'col',
}, },
selectable: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
@ -18,10 +22,11 @@ const handleClick = () => {
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2" class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
> >
<div <div
class="flex w-full gap-3 px-6 py-5" class="flex w-full gap-3 py-5"
:class=" :class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center' layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
" selectable ? 'px-10 py-6' : 'px-6',
]"
@click="handleClick" @click="handleClick"
> >
<slot /> <slot />

View File

@ -65,8 +65,8 @@ const toggleBlock = () => {
<div <div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56" class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
> >
<header class="sticky top-0 z-10 px-6 xl:px-0"> <header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[650px]"> <div class="w-full mx-auto max-w-[40.625rem]">
<div class="flex items-center justify-between w-full h-20 gap-2"> <div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb <Breadcrumb
:items="breadcrumbItems" :items="breadcrumbItems"
@ -98,8 +98,8 @@ const toggleBlock = () => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px"> <main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]"> <div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot name="default" /> <slot name="default" />
</div> </div>
</main> </main>
@ -107,7 +107,7 @@ const toggleBlock = () => {
<div <div
v-if="slots.sidebar" v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2" class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
> >
<slot name="sidebar" /> <slot name="sidebar" />
</div> </div>

View File

@ -50,6 +50,7 @@ const { t } = useI18n();
</div> </div>
<ComboBox <ComboBox
id="inbox" id="inbox"
use-api-results
:model-value="primaryContactId" :model-value="primaryContactId"
:options="primaryContactList" :options="primaryContactList"
:empty-state=" :empty-state="

View File

@ -60,7 +60,7 @@ const emit = defineEmits([
<template> <template>
<header class="sticky top-0 z-10"> <header class="sticky top-0 z-10">
<div <div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]" class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
> >
<span class="text-xl font-medium truncate text-n-slate-12"> <span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}

View File

@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS') t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
" "
:show-clear-button="!hasActiveSegments" :show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6" class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')" @open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
/> />

View File

@ -72,7 +72,7 @@ const openFilter = () => {
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
/> />
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if=" v-if="
(hasAppliedFilters || !isNotSegmentView) && (hasAppliedFilters || !isNotSegmentView) &&

View File

@ -22,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden" class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
> >
<div <div
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]" class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
> >
<div <div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none" class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"

View File

@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0"> <header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto lg:px-6"> <div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div <div
v-if="showHeaderTitle" v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2" class="flex items-center justify-start h-20 gap-2"
@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0"> <main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6"> <div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" /> <slot name="content" />
</div> </div>
</main> </main>

View File

@ -52,7 +52,7 @@ onMounted(() => {
<template> <template>
<div <div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6" class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3> <h3>
@ -75,7 +75,7 @@ onMounted(() => {
<div> <div>
<div class="flex justify-between w-full gap-4 py-2"> <div class="flex justify-between w-full gap-4 py-2">
<label <label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50" class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-slate-900 dark:text-slate-50"
> >
{{ {{
t( t(
@ -90,9 +90,9 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER' 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
) )
" "
class="w-[220px]" class="w-[13.75rem]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none" custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]" custom-text-area-class="max-h-[9.375rem]"
auto-height auto-height
min-height="3rem" min-height="3rem"
/> />
@ -108,12 +108,12 @@ onMounted(() => {
:label=" :label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE') t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
" "
custom-label-class="min-w-[120px]" custom-label-class="min-w-[7.5rem]"
/> />
</div> </div>
<div class="flex justify-between w-full gap-3 py-2"> <div class="flex justify-between w-full gap-3 py-2">
<label <label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50" class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-slate-900 dark:text-slate-50"
> >
{{ {{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS') t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
@ -126,7 +126,7 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER' 'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
) )
" "
class="w-[224px]" class="w-[14rem]"
/> />
</div> </div>
</div> </div>

View File

@ -90,7 +90,7 @@ const handleCategory = async formData => {
<template> <template>
<div <div
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6" class="w-[25rem] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
> >
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50"> <h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ {{

View File

@ -201,7 +201,7 @@ defineExpose({ state, isSubmitDisabled });
color="slate" color="slate"
size="sm" size="sm"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''" :icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none" class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen" @click="isEmojiPickerOpen = !isEmojiPickerOpen"
/> />
<EmojiInput <EmojiInput

View File

@ -74,7 +74,7 @@ const handleDeletePortal = () => {
</div> </div>
<div <div
v-else-if="activePortal" v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8" class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
> >
<PortalBaseSettings <PortalBaseSettings
:active-portal="activePortal" :active-portal="activePortal"

View File

@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
<template> <template>
<div <div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4" class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
> >
<div <div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2" class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"

View File

@ -13,6 +13,7 @@ import {
createNewContact, createNewContact,
fetchContactableInboxes, fetchContactableInboxes,
processContactableInboxes, processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper'; } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue'; import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@ -47,6 +48,7 @@ const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get'); const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags'); const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature'); const messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const sendWithSignature = computed(() => const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType) fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
@ -104,7 +106,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
isFetchingInboxes.value = true; isFetchingInboxes.value = true;
try { try {
const contactableInboxes = await fetchContactableInboxes(contact.id); const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes; // Merge the processed contactableInboxes with the inboxesList
selectedContact.value.contactInboxes = mergeInboxDetails(
contactableInboxes,
inboxesList.value
);
isFetchingInboxes.value = false; isFetchingInboxes.value = false;
} catch (error) { } catch (error) {
isFetchingInboxes.value = false; isFetchingInboxes.value = false;
@ -162,9 +169,12 @@ watch(
() => { () => {
if (activeContact.value && props.contactId) { if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || []; const contactInboxes = activeContact.value?.contactInboxes || [];
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(contactInboxes);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = { selectedContact.value = {
...activeContact.value, ...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes), contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
}; };
} }
}, },

View File

@ -148,7 +148,7 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<div <div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3" class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<WhatsAppOptions <WhatsAppOptions

View File

@ -47,10 +47,10 @@ const removeAttachment = id => {
<div <div
v-for="attachment in filteredImageAttachments" v-for="attachment in filteredImageAttachments"
:key="attachment.id" :key="attachment.id"
class="relative group/image w-[72px] h-[72px]" class="relative group/image w-[4.5rem] h-[4.5rem]"
> >
<img <img
class="object-cover w-[72px] h-[72px] rounded-lg" class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
:src="attachment.thumb" :src="attachment.thumb"
/> />
<Button <Button
@ -69,7 +69,7 @@ const removeAttachment = id => {
<div <div
v-for="attachment in filteredNonImageAttachments" v-for="attachment in filteredNonImageAttachments"
:key="attachment.id" :key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2" class="max-w-[18.75rem] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
> >
<span class="text-sm font-medium text-n-slate-11"> <span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }} {{ fileNameWithEllipsis(attachment.resource) }}

View File

@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template> <template>
<div <div
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl" class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
> >
<ContactSelector <ContactSelector
:contacts="contacts" :contacts="contacts"

View File

@ -87,6 +87,21 @@ export const processContactableInboxes = inboxes => {
})); }));
}; };
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
if (!inboxesData || !inboxesData.length) {
return [];
}
return inboxesData.map(inboxData => {
const matchingInbox =
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
return {
...camelcaseKeys(matchingInbox, { deep: true }),
...inboxData,
};
});
};
export const prepareAttachmentPayload = ( export const prepareAttachmentPayload = (
attachedFiles, attachedFiles,
directUploadsEnabled directUploadsEnabled

View File

@ -110,6 +110,153 @@ describe('composeConversationHelper', () => {
}); });
}); });
describe('mergeInboxDetails', () => {
it('returns empty array if inboxesData is empty or null', () => {
expect(helpers.mergeInboxDetails(null)).toEqual([]);
expect(helpers.mergeInboxDetails([])).toEqual([]);
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
});
it('merges inbox data with matching inboxes from the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 2, sourceId: 'source2' },
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
channel_id: 10,
phone_number: null,
},
{
id: 2,
name: 'Inbox 2',
channel_type: 'Channel::Whatsapp',
channel_id: 20,
phone_number: '+1234567890',
},
{
id: 3,
name: 'Inbox 3',
channel_type: 'Channel::Api',
channel_id: 30,
phone_number: null,
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
channelId: 10,
phoneNumber: null,
});
expect(result[1]).toMatchObject({
id: 2,
sourceId: 'source2',
name: 'Inbox 2',
channelType: 'Channel::Whatsapp',
channelId: 20,
phoneNumber: '+1234567890',
});
});
it('handles inboxes not found in the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
});
expect(result[1]).toMatchObject({
id: 99,
sourceId: 'source99',
});
expect(result[1].name).toBeUndefined();
expect(result[1].channelType).toBeUndefined();
});
it('camelcases properties from inboxesList', () => {
const inboxesData = [{ id: 1, sourceId: 'source1' }];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
avatar_url: 'https://example.com/avatar.png',
working_hours: [
{
day_of_week: 1,
closed_all_day: false,
},
],
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
avatarUrl: 'https://example.com/avatar.png',
});
expect(result[0].workingHours[0]).toMatchObject({
dayOfWeek: 1,
closedAllDay: false,
});
});
it('preserves original properties when they conflict with inboxesList', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1', name: 'Original Name' },
];
const inboxesList = [
{
id: 1,
name: 'List Name',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0].name).toBe('Original Name');
expect(result[0].channelType).toBe('Channel::Email');
});
});
describe('prepareAttachmentPayload', () => { describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => { it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }]; const files = [{ blobSignedId: 'signed1' }];

View File

@ -99,45 +99,50 @@ const computedJustify = computed(() => {
const STYLE_CONFIG = { const STYLE_CONFIG = {
colors: { colors: {
blue: { blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent', solid:
'bg-n-brand text-white hover:enabled:brightness-110 outline-transparent',
faded: faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent', 'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border', outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent', ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent', link: 'text-n-blue-text hover:enabled:underline outline-transparent',
}, },
ruby: { ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent', solid:
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 outline-transparent',
faded: faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent', 'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8', outline: 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent', ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:underline outline-transparent', link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
}, },
amber: { amber: {
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent', solid:
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 outline-transparent',
faded: faded:
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent', 'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9', outline:
link: 'text-n-amber-9 hover:underline outline-transparent', 'text-n-amber-11 hover:enabled:bg-n-amber-9/10 outline-n-amber-9',
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent', link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
}, },
slate: { slate: {
solid: solid:
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container', 'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 hover:enabled:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded: faded:
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent', 'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10', outline: 'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent', link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent', ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
}, },
teal: { teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent', solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 outline-transparent',
faded: faded:
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent', 'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9', outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent', link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent', ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
}, },
}, },
sizes: { sizes: {
@ -171,7 +176,7 @@ const STYLE_CONFIG = {
center: 'justify-center', center: 'justify-center',
end: 'justify-end', end: 'justify-end',
}, },
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50', base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
}; };
const variantClasses = computed(() => { const variantClasses = computed(() => {

View File

@ -68,7 +68,7 @@ const handlePageChange = event => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0"> <header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[960px] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
@ -96,7 +96,7 @@ const handlePageChange = event => {
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0"> <main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4"> <div class="w-full max-w-[60rem] mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
v-if="isFetching" v-if="isFetching"

View File

@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
const props = defineProps({ const props = defineProps({
@ -46,14 +47,27 @@ const props = defineProps({
type: Number, type: Number,
required: true, required: true,
}, },
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['action', 'navigate']); const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n(); const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle(); const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const statusAction = computed(() => { const statusAction = computed(() => {
if (props.status === 'pending') { if (props.status === 'pending') {
return [ return [
@ -102,8 +116,17 @@ const handleDocumentableClick = () => {
</script> </script>
<template> <template>
<CardLayout :class="{ 'rounded-md': compact }"> <CardLayout
<div class="flex justify-between w-full gap-1"> selectable
class="relative"
:class="{ 'rounded-md': compact }"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1"> <span class="text-base text-n-slate-12 line-clamp-1">
{{ question }} {{ question }}
</span> </span>
@ -148,7 +171,7 @@ const handleDocumentableClick = () => {
v-if="documentable.type === 'Captain::Document'" v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over" class="inline-flex items-center gap-1 truncate over"
> >
<i class="i-ph-chat-circle-dots text-base" /> <i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name"> <span class="max-w-96 truncate" :title="documentable.name">
{{ documentable.name }} {{ documentable.name }}
</span> </span>

View File

@ -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>

View File

@ -27,7 +27,7 @@ const openBilling = () => {
<template> <template>
<div <div
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center" class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
> >
<BasePaywallModal <BasePaywallModal
class="mx-auto" class="mx-auto"

View File

@ -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>

View File

@ -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>

View File

@ -13,34 +13,14 @@ const props = defineProps({
validator: value => validator: value =>
value.every(option => 'value' in option && 'label' in option), value.every(option => 'value' in option && 'label' in option),
}, },
placeholder: { placeholder: { type: String, default: '' },
type: String, modelValue: { type: [String, Number], default: '' },
default: '', disabled: { type: Boolean, default: false },
}, searchPlaceholder: { type: String, default: '' },
modelValue: { emptyState: { type: String, default: '' },
type: [String, Number], message: { type: String, default: '' },
default: '', hasError: { type: Boolean, default: false },
}, useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['update:modelValue', 'search']); const emit = defineEmits(['update:modelValue', 'search']);
@ -54,6 +34,12 @@ const dropdownRef = ref(null);
const comboboxRef = ref(null); const comboboxRef = ref(null);
const filteredOptions = computed(() => { const filteredOptions = computed(() => {
// For API search, don't filter options locally
if (props.useApiResults && search.value) {
return props.options;
}
// For local search, filter options based on search term
const searchTerm = search.value.toLowerCase(); const searchTerm = search.value.toLowerCase();
return props.options.filter(option => return props.options.filter(option =>
option.label.toLowerCase().includes(searchTerm) option.label.toLowerCase().includes(searchTerm)

View File

@ -63,7 +63,7 @@ const pageInfo = computed(() => {
<template> <template>
<div <div
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none" class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11"> <span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">

View File

@ -185,7 +185,7 @@ watch(
" "
trailing-icon trailing-icon
:disabled="disabled" :disabled="disabled"
class="!h-[30px] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none" class="!h-[1.875rem] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="toggleCountryDropdown" @click="toggleCountryDropdown"
> >
<span <span

View File

@ -515,7 +515,7 @@ const menuItems = computed(() => {
<template> <template>
<aside <aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1" class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2"> <div class="flex items-center min-w-0 gap-2 px-2">

View File

@ -31,7 +31,7 @@ const shouldRenderComponent = computed(() => {
:is="to ? 'router-link' : 'div'" :is="to ? 'router-link' : 'div'"
:to="to" :to="to"
:title="label" :title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[151px] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group" class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
:class="{ :class="{
'n-blue-text bg-n-alpha-2 active': active, 'n-blue-text bg-n-alpha-2 active': active,
}" }"

View File

@ -13,6 +13,7 @@ import {
DropdownSeparator, DropdownSeparator,
DropdownItem, DropdownItem,
} from 'next/dropdown-menu/base'; } from 'next/dropdown-menu/base';
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
const emit = defineEmits(['close', 'openKeyShortcutModal']); const emit = defineEmits(['close', 'openKeyShortcutModal']);
@ -43,6 +44,7 @@ const menuItems = computed(() => {
return [ return [
{ {
show: showChatSupport.value, show: showChatSupport.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'), label: t('SIDEBAR_ITEMS.CONTACT_SUPPORT'),
icon: 'i-lucide-life-buoy', icon: 'i-lucide-life-buoy',
click: () => { click: () => {
@ -51,6 +53,7 @@ const menuItems = computed(() => {
}, },
{ {
show: true, show: true,
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'), label: t('SIDEBAR_ITEMS.KEYBOARD_SHORTCUTS'),
icon: 'i-lucide-keyboard', icon: 'i-lucide-keyboard',
click: () => { click: () => {
@ -59,12 +62,14 @@ const menuItems = computed(() => {
}, },
{ {
show: true, show: true,
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'), label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen', icon: 'i-lucide-user-pen',
link: { name: 'profile_settings_index' }, link: { name: 'profile_settings_index' },
}, },
{ {
show: true, show: true,
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.APPEARANCE'), label: t('SIDEBAR_ITEMS.APPEARANCE'),
icon: 'i-lucide-palette', icon: 'i-lucide-palette',
click: () => { click: () => {
@ -74,6 +79,7 @@ const menuItems = computed(() => {
}, },
{ {
show: true, show: true,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.DOCS'), label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book', icon: 'i-lucide-book',
link: 'https://www.chatwoot.com/hc/user-guide/en', link: 'https://www.chatwoot.com/hc/user-guide/en',
@ -82,6 +88,7 @@ const menuItems = computed(() => {
}, },
{ {
show: currentUser.value.type === 'SuperAdmin', show: currentUser.value.type === 'SuperAdmin',
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'), label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
icon: 'i-lucide-castle', icon: 'i-lucide-castle',
link: '/super_admin', link: '/super_admin',
@ -90,6 +97,7 @@ const menuItems = computed(() => {
}, },
{ {
show: true, show: true,
showOnCustomBrandedInstance: true,
label: t('SIDEBAR_ITEMS.LOGOUT'), label: t('SIDEBAR_ITEMS.LOGOUT'),
icon: 'i-lucide-power', icon: 'i-lucide-power',
click: Auth.logout, click: Auth.logout,
@ -136,7 +144,11 @@ const allowedMenuItems = computed(() => {
<SidebarProfileMenuStatus /> <SidebarProfileMenuStatus />
<DropdownSeparator /> <DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label"> <template v-for="item in allowedMenuItems" :key="item.label">
<DropdownItem v-if="item.show" v-bind="item" /> <CustomBrandPolicyWrapper
:show-on-custom-branded-instance="item.showOnCustomBrandedInstance"
>
<DropdownItem v-if="item.show" v-bind="item" />
</CustomBrandPolicyWrapper>
</template> </template>
</DropdownBody> </DropdownBody>
</DropdownContainer> </DropdownContainer>

View File

@ -27,7 +27,7 @@ const updateValue = () => {
> >
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span> <span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span <span
class="absolute top-px left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out" class="absolute top-[0.07rem] left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
:class=" :class="
modelValue modelValue
? 'translate-x-2.5 bg-white' ? 'translate-x-2.5 bg-white'

View File

@ -675,6 +675,15 @@ async function markAsUnread(conversationId) {
// Ignore error // Ignore error
} }
} }
async function markAsRead(conversationId) {
try {
await store.dispatch('markMessagesRead', {
id: conversationId,
});
} catch (error) {
// Ignore error
}
}
async function onAssignTeam(team, conversationId = null) { async function onAssignTeam(team, conversationId = null) {
try { try {
await store.dispatch('assignTeam', { await store.dispatch('assignTeam', {
@ -744,6 +753,7 @@ provide('assignLabels', onAssignLabels);
provide('updateConversationStatus', toggleConversationStatus); provide('updateConversationStatus', toggleConversationStatus);
provide('toggleContextMenu', onContextMenuToggle); provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread); provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority); provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected); provide('isConversationSelected', isConversationSelected);

View File

@ -13,6 +13,7 @@ export default {
'updateConversationStatus', 'updateConversationStatus',
'toggleContextMenu', 'toggleContextMenu',
'markAsUnread', 'markAsUnread',
'markAsRead',
'assignPriority', 'assignPriority',
'isConversationSelected', 'isConversationSelected',
], ],
@ -64,6 +65,7 @@ export default {
@update-conversation-status="updateConversationStatus" @update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu" @context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
/> />
</template> </template>

View File

@ -23,33 +23,34 @@ export default {
<template> <template>
<div <div
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row" class="ml-0 mr-0 py-8 w-full"
:class="{ :class="{
'border-b border-solid border-slate-50 dark:border-slate-700/30': 'border-b border-solid border-n-weak/60 dark:border-n-weak': showBorder,
showBorder,
}" }"
> >
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12"> <div class="grid grid-cols-1 lg:grid-cols-6 gap-6">
<p <div class="col-span-2 xl:col-span-1">
v-if="title" <p
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium" v-if="title"
> class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
{{ title }} >
</p> {{ title }}
<p </p>
class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2" <p
> class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2"
<slot v-if="subTitle" name="subTitle"> >
{{ subTitle }} <slot v-if="subTitle" name="subTitle">
</slot> {{ subTitle }}
</p> </slot>
<p v-if="note"> </p>
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span> <p v-if="note">
{{ note }} <span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
</p> {{ note }}
</div> </p>
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]"> </div>
<slot /> <div class="col-span-4 lg:col-span-4 2xl:col-span-3">
<slot />
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -23,17 +23,16 @@ export default {
<template> <template>
<div <div
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-slate-75 dark:border-slate-600" class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-n-strong"
:class="{ :class="{
'bg-woot-25 dark:bg-slate-700 border border-solid border-woot-300 dark:border-woot-400': 'bg-woot-25 dark:bg-n-solid-2 border border-solid border-n-blue-border':
active, active,
}" }"
> >
<div <div
class="flex justify-between items-center px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 rounded-t-[5px] border-b border-solid border-slate-50 dark:border-slate-600" class="flex justify-between items-center rounded-t-md px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 border-b border-solid border-n-strong"
:class="{ :class="{
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700': 'bg-woot-50 border-b border-solid border-n-blue-border': active,
active,
}" }"
> >
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div> <div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>

View File

@ -38,20 +38,20 @@ export default {
cursor: pointer; cursor: pointer;
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
height: 19px; height: 1.188rem;
position: relative; position: relative;
transition-duration: 200ms; transition-duration: 200ms;
transition-property: background-color; transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 34px; width: 2.125rem;
&.active { &.active {
background-color: var(--w-500); background-color: var(--w-500);
} }
&.small { &.small {
width: 22px; width: 1.375rem;
height: 14px; height: 0.875rem;
span { span {
height: var(--space-one); height: var(--space-one);

View File

@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, provide, onMounted, computed } from 'vue'; import { ref, useTemplateRef, provide, computed, watch } from 'vue';
import { useEventListener } from '@vueuse/core'; import { useElementSize } from '@vueuse/core';
const props = defineProps({ const props = defineProps({
index: { index: {
@ -19,6 +19,12 @@ const props = defineProps({
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
const tabsContainer = useTemplateRef('tabsContainer');
const tabsList = useTemplateRef('tabsList');
const { width: containerWidth } = useElementSize(tabsContainer);
const { width: listWidth } = useElementSize(tabsList);
const hasScroll = ref(false); const hasScroll = ref(false);
const activeIndex = computed({ const activeIndex = computed({
@ -34,20 +40,16 @@ provide('updateActiveIndex', index => {
}); });
const computeScrollWidth = () => { const computeScrollWidth = () => {
// TODO: use useElementSize from vueuse if (tabsContainer.value && tabsList.value) {
const tabElement = document.querySelector('.tabs'); hasScroll.value = tabsList.value.scrollWidth > tabsList.value.clientWidth;
if (tabElement) {
hasScroll.value = tabElement.scrollWidth > tabElement.clientWidth;
} }
}; };
const onScrollClick = direction => { const onScrollClick = direction => {
// TODO: use useElementSize from vueuse if (tabsContainer.value && tabsList.value) {
const tabElement = document.querySelector('.tabs'); let scrollPosition = tabsList.value.scrollLeft;
if (tabElement) {
let scrollPosition = tabElement.scrollLeft;
scrollPosition += direction === 'left' ? -100 : 100; scrollPosition += direction === 'left' ? -100 : 100;
tabElement.scrollTo({ tabsList.value.scrollTo({
top: 0, top: 0,
left: scrollPosition, left: scrollPosition,
behavior: 'smooth', behavior: 'smooth',
@ -55,14 +57,19 @@ const onScrollClick = direction => {
} }
}; };
useEventListener(window, 'resize', computeScrollWidth); // Watch for changes in element sizes with immediate execution
onMounted(() => { watch(
computeScrollWidth(); [containerWidth, listWidth],
}); () => {
computeScrollWidth();
},
{ immediate: true }
);
</script> </script>
<template> <template>
<div <div
ref="tabsContainer"
:class="{ :class="{
'tabs--container--with-border': border, 'tabs--container--with-border': border,
'tabs--container--compact': isCompact, 'tabs--container--compact': isCompact,
@ -76,7 +83,7 @@ onMounted(() => {
> >
<fluent-icon icon="chevron-left" :size="16" /> <fluent-icon icon="chevron-left" :size="16" />
</button> </button>
<ul :class="{ 'tabs--with-scroll': hasScroll }" class="tabs"> <ul ref="tabsList" :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
<slot /> <slot />
</ul> </ul>
<button <button

View File

@ -72,26 +72,26 @@ export default {
&.active { &.active {
h3 { h3 {
@apply text-woot-500 dark:text-woot-500; @apply text-n-blue-text dark:text-n-blue-text;
} }
.step { .step {
@apply bg-woot-500 dark:bg-woot-500; @apply bg-n-brand dark:bg-n-brand;
} }
} }
&.over { &.over {
&::after { &::after {
@apply bg-woot-500 dark:bg-woot-500; @apply bg-n-brand dark:bg-n-brand;
} }
.step { .step {
@apply bg-woot-500 dark:bg-woot-500; @apply bg-n-brand dark:bg-n-brand;
} }
& + .item { & + .item {
&::before { &::before {
@apply bg-woot-500 dark:bg-woot-500; @apply bg-n-brand dark:bg-n-brand;
} }
} }
} }

View File

@ -201,10 +201,10 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.filter { .filter {
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2; @apply bg-n-slate-3 dark:bg-n-solid-3 p-2 border border-solid border-n-strong dark:border-n-strong rounded-md mb-2;
&.is-a-macro { &.is-a-macro {
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none; @apply mb-0 bg-n-slate-2 dark:bg-n-solid-3 p-0 border-0 rounded-none;
} }
} }

View File

@ -128,7 +128,7 @@ export default {
getInputErrorClass(errorMessage) { getInputErrorClass(errorMessage) {
return errorMessage return errorMessage
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50' ? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
: 'bg-slate-50 dark:bg-slate-800 border-slate-75 dark:border-slate-700/50'; : 'bg-n-slate-3 dark:bg-n-solid-3 border-n-strong dark:border-n-strong';
}, },
}, },
}; };

View File

@ -14,7 +14,7 @@ export default {
</script> </script>
<template> <template>
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak"> <div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak/60">
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium"> <h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
{{ headerTitle }} {{ headerTitle }}
</h2> </h2>

View File

@ -127,7 +127,7 @@ export default {
const uploadRef = ref(false); const uploadRef = ref(false);
const keyboardEvents = { const keyboardEvents = {
'Alt+KeyA': { '$mod+Alt+KeyA': {
action: () => { action: () => {
// TODO: This is really hacky, we need to replace the file picker component with // TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated. // a custom one, where the logic and the component markup is isolated.

View File

@ -85,7 +85,7 @@ export default {
</script> </script>
<template> <template>
<div class="flex justify-between h-[52px] gap-2 ltr:pl-3 rtl:pr-3"> <div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<EditorModeToggle <EditorModeToggle
:mode="mode" :mode="mode"
class="mt-3" class="mt-3"

View File

@ -75,6 +75,7 @@ export default {
'assignLabel', 'assignLabel',
'assignTeam', 'assignTeam',
'markAsUnread', 'markAsUnread',
'markAsRead',
'assignPriority', 'assignPriority',
'updateConversationStatus', 'updateConversationStatus',
], ],
@ -228,6 +229,10 @@ export default {
this.$emit('markAsUnread', this.chat.id); this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
}, },
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) { async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id); this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
@ -356,6 +361,7 @@ export default {
@assign-label="onAssignLabel" @assign-label="onAssignLabel"
@assign-team="onAssignTeam" @assign-team="onAssignTeam"
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
/> />
</ContextMenu> </ContextMenu>
@ -405,7 +411,7 @@ export default {
} }
.checkbox-wrapper { .checkbox-wrapper {
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4; @apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] { input[type='checkbox'] {
@apply m-0 cursor-pointer; @apply m-0 cursor-pointer;

View File

@ -53,7 +53,7 @@ const showCopilotTab = computed(() =>
<template> <template>
<div <div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[320px] w-[320px] 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background" class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
> >
<div v-if="showCopilotTab" class="p-2"> <div v-if="showCopilotTab" class="p-2">
<TabBar <TabBar

View File

@ -41,6 +41,7 @@ export default {
'updateConversation', 'updateConversation',
'assignPriority', 'assignPriority',
'markAsUnread', 'markAsUnread',
'markAsRead',
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
@ -48,6 +49,10 @@ export default {
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
icon: 'mail',
},
unreadOption: { unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail', icon: 'mail',
@ -58,16 +63,16 @@ export default {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.RESOLVED'),
icon: 'checkmark', icon: 'checkmark',
}, },
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
{ {
key: wootConstants.STATUS_TYPE.OPEN, key: wootConstants.STATUS_TYPE.OPEN,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.REOPEN'),
icon: 'arrow-redo', icon: 'arrow-redo',
}, },
{
key: wootConstants.STATUS_TYPE.PENDING,
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.PENDING'),
icon: 'book-clock',
},
], ],
snoozeOption: { snoozeOption: {
key: wootConstants.STATUS_TYPE.SNOOZED, key: wootConstants.STATUS_TYPE.SNOOZED,
@ -203,6 +208,13 @@ export default {
variant="icon" variant="icon"
@click.stop="$emit('markAsUnread')" @click.stop="$emit('markAsUnread')"
/> />
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-for="option in statusMenuConfig"> <template v-for="option in statusMenuConfig">
<MenuItem <MenuItem
v-if="show(option.key)" v-if="show(option.key)"
@ -218,7 +230,7 @@ export default {
variant="icon" variant="icon"
@click.stop="snoozeConversation()" @click.stop="snoozeConversation()"
/> />
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItemWithSubmenu :option="priorityConfig"> <MenuItemWithSubmenu :option="priorityConfig">
<MenuItem <MenuItem
v-for="(option, i) in priorityConfig.options" v-for="(option, i) in priorityConfig.options"

View File

@ -45,7 +45,7 @@ const statusDesiredOrder = [
]; ];
const isCreating = ref(false); const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' }; const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
const formState = reactive({ const formState = reactive({
title: '', title: '',
@ -209,7 +209,7 @@ onMounted(getTeams);
v-model="formState.title" v-model="formState.title"
:class="{ error: v$.title.$error }" :class="{ error: v$.title.$error }"
class="w-full" class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }" :styles="{ ...inputStyles, padding: '0.375rem 0.75rem' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')" :label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder=" :placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER') $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
@ -221,7 +221,7 @@ onMounted(getTeams);
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }} {{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea <textarea
v-model="formState.description" v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }" :style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
rows="3" rows="3"
class="text-sm" class="text-sm"
:placeholder=" :placeholder="

View File

@ -1,41 +1,34 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout'; import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { SHORTCUT_KEYS } from './constants'; import { SHORTCUT_KEYS, KEYS } from './constants';
import { import {
LAYOUT_QWERTZ, LAYOUT_QWERTZ,
keysToModifyInQWERTZ, keysToModifyInQWERTZ,
} from 'shared/helpers/KeyboardHelpers'; } from 'shared/helpers/KeyboardHelpers';
import Hotkey from 'dashboard/components/base/Hotkey.vue'; import Hotkey from 'dashboard/components/base/Hotkey.vue';
defineProps({ defineProps({ show: Boolean });
show: {
type: Boolean,
default: false,
},
});
defineEmits(['close']); defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const shortcutKeys = SHORTCUT_KEYS;
const currentLayout = ref(null); const currentLayout = ref(null);
const title = item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`); const title = computed(
() => item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`)
);
// Added this function to check if the keySet needs a shift key // Added this function to check if the keySet needs a shift key
// This is used to display the shift key in the modal // This is used to display the shift key in the modal
// If the current layout is QWERTZ and the keySet contains a key that needs a shift key // If the current layout is QWERTZ and the keySet contains a key that needs a shift key
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue // If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492 // https://github.com/chatwoot/chatwoot/issues/9492
const needsShiftKey = keySet => { const needsShiftKey = computed(
return ( () => keySet =>
currentLayout.value === LAYOUT_QWERTZ && currentLayout.value === LAYOUT_QWERTZ &&
keySet.some(key => keysToModifyInQWERTZ.has(key)) keySet.some(key => keysToModifyInQWERTZ.has(key))
); );
};
onMounted(async () => { onMounted(async () => {
currentLayout.value = await useDetectKeyboardLayout(); currentLayout.value = await useDetectKeyboardLayout();
@ -55,80 +48,46 @@ onMounted(async () => {
</h5> </h5>
<div class="flex items-center gap-2 mb-1 ml-2"> <div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key"> <Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.WINDOWS_KEY_AND_COMMAND_KEY') }} {{ KEYS.WIN }}
</Hotkey> </Hotkey>
<Hotkey custom-class="min-h-[28px] min-w-[36px] key"> <Hotkey custom-class="min-h-[28px] min-w-[36px] key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }} {{ KEYS.SLASH }}
</Hotkey> </Hotkey>
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-2 px-8 pt-0 pb-8 gap-x-5 gap-y-3"> <div class="grid grid-cols-2 px-8 pt-0 pb-8 gap-x-5 gap-y-3">
<div class="flex justify-between items-center min-w-[25rem]">
<h5 class="text-sm text-slate-800 dark:text-slate-100">
{{ $t('KEYBOARD_SHORTCUTS.TITLE.OPEN_CONVERSATION') }}
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<div class="flex gap-2">
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</Hotkey>
<Hotkey custom-class="min-h-[28px] w-9 key"> {{ 'J' }} </Hotkey>
<span
class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
>
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
</span>
</div>
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</Hotkey>
<Hotkey custom-class="w-9 key"> {{ 'K' }} </Hotkey>
</div>
</div>
<div class="flex justify-between items-center min-w-[25rem]">
<h5 class="text-sm text-slate-800 dark:text-slate-100">
{{ $t('KEYBOARD_SHORTCUTS.TITLE.RESOLVE_AND_NEXT') }}
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.WINDOWS_KEY_AND_COMMAND_KEY') }}
</Hotkey>
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</Hotkey>
<Hotkey custom-class="w-9 key"> {{ 'E' }} </Hotkey>
</div>
</div>
<div <div
v-for="shortcutKey in shortcutKeys" v-for="shortcut in SHORTCUT_KEYS"
:key="shortcutKey.id" :key="shortcut.id"
class="flex justify-between items-center min-w-[25rem]" class="flex justify-between items-center min-w-[25rem]"
> >
<h5 class="text-sm text-slate-800 min-w-[36px] dark:text-slate-100"> <h5 class="text-sm text-slate-800 min-w-[36px] dark:text-slate-100">
{{ title(shortcutKey) }} {{ title(shortcut) }}
</h5> </h5>
<div class="flex items-center gap-2 mb-1 ml-2"> <div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey <template v-if="needsShiftKey(shortcut.keySet)">
v-if="needsShiftKey(shortcutKey.keySet)" <Hotkey custom-class="min-h-[28px] min-w-[36px] key">
custom-class="min-h-[28px] min-w-[36px] key" {{ KEYS.SHIFT }}
> </Hotkey>
{{ 'Shift' }} </template>
</Hotkey>
<Hotkey <template v-for="(key, index) in shortcut.displayKeys" :key="index">
:class="{ 'min-w-[60px]': shortcutKey.firstKey !== 'Up' }" <template v-if="key !== KEYS.SLASH">
custom-class="min-h-[28px] normal-case key" <Hotkey
> custom-class="min-h-[28px] min-w-[36px] key normal-case"
{{ shortcutKey.firstKey }} >
</Hotkey> {{ key }}
<Hotkey </Hotkey>
:class="{ 'normal-case': shortcutKey.secondKey === 'Down' }" </template>
custom-class="min-h-[28px] min-w-[36px] key" <span
> v-else
{{ shortcutKey.secondKey }} class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
</Hotkey> >
{{ key }}
</span>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,86 +1,95 @@
export const KEYS = {
ALT: 'Alt / ⌥',
WIN: 'Win / ⌘',
SHIFT: 'Shift',
SLASH: '/',
UP: 'Up',
DOWN: 'Down',
};
export const SHORTCUT_KEYS = [ export const SHORTCUT_KEYS = [
{ {
id: 1, id: 1,
label: 'NAVIGATE_DROPDOWN', label: 'OPEN_CONVERSATION',
firstKey: 'Up', displayKeys: [KEYS.ALT, 'J', KEYS.SLASH, KEYS.ALT, 'K'],
secondKey: 'Down', keySet: ['Alt+KeyJ', 'Alt+KeyK'],
keySet: ['ArrowUp', 'ArrowDown'],
}, },
{ {
id: 2, id: 2,
label: 'RESOLVE_CONVERSATION', label: 'RESOLVE_AND_NEXT',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.WIN, KEYS.ALT, 'E'],
secondKey: 'E', keySet: ['$mod+Alt+KeyE'],
keySet: ['Alt+KeyE'],
}, },
{ {
id: 3, id: 3,
label: 'GO_TO_CONVERSATION_DASHBOARD', label: 'NAVIGATE_DROPDOWN',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.UP, KEYS.DOWN],
secondKey: 'C', keySet: ['ArrowUp', 'ArrowDown'],
keySet: ['Alt+KeyC'],
}, },
{ {
id: 4, id: 4,
label: 'ADD_ATTACHMENT', label: 'RESOLVE_CONVERSATION',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'E'],
secondKey: 'A', keySet: ['Alt+KeyE'],
keySet: ['Alt+KeyA'],
}, },
{ {
id: 5, id: 5,
label: 'GO_TO_CONTACTS_DASHBOARD', label: 'GO_TO_CONVERSATION_DASHBOARD',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'C'],
secondKey: 'V', keySet: ['Alt+KeyC'],
keySet: ['Alt+KeyV'],
}, },
{ {
id: 6, id: 6,
label: 'TOGGLE_SIDEBAR', label: 'ADD_ATTACHMENT',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.WIN, KEYS.ALT, 'A'],
secondKey: 'O', keySet: ['$mod+Alt+KeyA'],
keySet: ['Alt+KeyO'],
}, },
{ {
id: 7, id: 7,
label: 'GO_TO_REPORTS_SIDEBAR', label: 'GO_TO_CONTACTS_DASHBOARD',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'V'],
secondKey: 'R', keySet: ['Alt+KeyV'],
keySet: ['Alt+KeyR'],
}, },
{ {
id: 8, id: 8,
label: 'MOVE_TO_NEXT_TAB', label: 'TOGGLE_SIDEBAR',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'O'],
secondKey: 'N', keySet: ['Alt+KeyO'],
keySet: ['Alt+KeyN'],
}, },
{ {
id: 9, id: 9,
label: 'GO_TO_SETTINGS', label: 'GO_TO_REPORTS_SIDEBAR',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'R'],
secondKey: 'S', keySet: ['Alt+KeyR'],
keySet: ['Alt+KeyS'], },
{
id: 10,
label: 'MOVE_TO_NEXT_TAB',
displayKeys: [KEYS.ALT, 'N'],
keySet: ['Alt+KeyN'],
}, },
{ {
id: 11, id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE', label: 'GO_TO_SETTINGS',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'S'],
secondKey: 'P', keySet: ['Alt+KeyS'],
keySet: ['Alt+KeyP'],
}, },
{ {
id: 12, id: 12,
label: 'SWITCH_TO_REPLY', label: 'SWITCH_TO_PRIVATE_NOTE',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'P'],
secondKey: 'L', keySet: ['Alt+KeyP'],
keySet: ['Alt+KeyL'],
}, },
{ {
id: 13, id: 13,
label: 'SWITCH_TO_REPLY',
displayKeys: [KEYS.ALT, 'L'],
keySet: ['Alt+KeyL'],
},
{
id: 14,
label: 'TOGGLE_SNOOZE_DROPDOWN', label: 'TOGGLE_SNOOZE_DROPDOWN',
firstKey: 'Alt / ⌥', displayKeys: [KEYS.ALT, 'M'],
secondKey: 'M',
keySet: ['Alt+KeyM'], keySet: ['Alt+KeyM'],
}, },
]; ];

View File

@ -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'
);
});
});

View File

@ -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;

View File

@ -9,9 +9,9 @@ export const setColorTheme = isOSOnDarkMode => {
selectedColorScheme === 'dark' selectedColorScheme === 'dark'
) { ) {
document.body.classList.add('dark'); document.body.classList.add('dark');
document.documentElement.setAttribute('style', 'color-scheme: dark;'); document.documentElement.style.setProperty('color-scheme', 'dark');
} else { } else {
document.body.classList.remove('dark'); document.body.classList.remove('dark');
document.documentElement.setAttribute('style', 'color-scheme: light;'); document.documentElement.style.setProperty('color-scheme', 'light');
} }
}; };

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "ERROR_MESSAGE" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Merge contacts", "SUBMIT": " Merge contacts",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message", "MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message", "SEND_MESSAGE": "Send message",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Contacts" "CONTACTS": "Contacts"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Contact saved successfully", "SUCCESS_MESSAGE": "Contact saved successfully",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.", "DESCRIPTION": "Import contacts through a CSV file.",

View File

@ -117,6 +117,7 @@
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread", "MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Reopen conversation", "REOPEN": "Reopen conversation",
"SNOOZE": { "SNOOZE": {
"TITLE": "Snooze", "TITLE": "Snooze",

View File

@ -300,6 +300,12 @@
"TITLE": "Unlink", "TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully", "SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again" "ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -35,6 +35,24 @@
} }
} }
}, },
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature", "TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.", "NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note", "SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply", "SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال من <strong>{primaryContactName}</strong> إلى <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال من <strong>{primaryContactName}</strong> إلى <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "رسالة_خطأ" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " دمج جهات الاتصال", "SUBMIT": " دمج جهات الاتصال",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "رسالة", "MESSAGE_BUTTON": "رسالة",
"SEND_MESSAGE": "إرسال الرسالة", "SEND_MESSAGE": "إرسال الرسالة",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "جهات الاتصال" "CONTACTS": "جهات الاتصال"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح", "SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "تم إلغاء حجب جهة الاتصال بنجاح",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "استيراد جهات الاتصال من خلال ملف CSV.", "DESCRIPTION": "استيراد جهات الاتصال من خلال ملف CSV.",

View File

@ -117,6 +117,7 @@
"PENDING": "تحديد كمعلق", "PENDING": "تحديد كمعلق",
"RESOLVED": "تحديد كمحلولة", "RESOLVED": "تحديد كمحلولة",
"MARK_AS_UNREAD": "وضع علامة كغير مقروء", "MARK_AS_UNREAD": "وضع علامة كغير مقروء",
"MARK_AS_READ": "تحديد كمقروء",
"REOPEN": "إعادة فتح المحادثة", "REOPEN": "إعادة فتح المحادثة",
"SNOOZE": { "SNOOZE": {
"TITLE": "تأجيل", "TITLE": "تأجيل",

View File

@ -300,6 +300,12 @@
"TITLE": "إلغاء الربط", "TITLE": "إلغاء الربط",
"SUCCESS": "تم إلغاء ربط المشكلة بنجاح", "SUCCESS": "تم إلغاء ربط المشكلة بنجاح",
"ERROR": "حدث خطأ أثناء إلغاء ربط المشكلة، الرجاء المحاولة مرة أخرى" "ERROR": "حدث خطأ أثناء إلغاء ربط المشكلة، الرجاء المحاولة مرة أخرى"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "نعم، احذف",
"CANCEL": "إلغاء"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "حذف",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -35,6 +35,24 @@
} }
} }
}, },
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "افتراضي",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "توقيع الرسالة الشخصية", "TITLE": "توقيع الرسالة الشخصية",
"NOTE": "إنشاء توقيع رسالة فريدة تظهر في نهاية كل رسالة ترسلها من أي صندوق وارد. يمكنك أيضًا تضمين صورة داخلية، مدعومة في الدردشة المباشرة، والبريد الإلكتروني، وصناديق API الواردة.", "NOTE": "إنشاء توقيع رسالة فريدة تظهر في نهاية كل رسالة ترسلها من أي صندوق وارد. يمكنك أيضًا تضمين صورة داخلية، مدعومة في الدردشة المباشرة، والبريد الإلكتروني، وصناديق API الواردة.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة", "SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة",
"SWITCH_TO_REPLY": "التبديل إلى الرد", "SWITCH_TO_REPLY": "التبديل إلى الرد",
"TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة" "TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘\n",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "ERROR_MESSAGE" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Merge contacts", "SUBMIT": " Merge contacts",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message", "MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message", "SEND_MESSAGE": "Send message",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Contacts" "CONTACTS": "Contacts"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Contact saved successfully", "SUCCESS_MESSAGE": "Contact saved successfully",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.", "DESCRIPTION": "Import contacts through a CSV file.",

View File

@ -117,6 +117,7 @@
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread", "MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Reopen conversation", "REOPEN": "Reopen conversation",
"SNOOZE": { "SNOOZE": {
"TITLE": "Snooze", "TITLE": "Snooze",

View File

@ -300,6 +300,12 @@
"TITLE": "Unlink", "TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully", "SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again" "ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -35,6 +35,24 @@
} }
} }
}, },
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature", "TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.", "NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note", "SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply", "SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>{primaryContactName}</strong> ще бъдат копирани в <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "Детайлите на контакт <strong>{primaryContactName}</strong> ще бъдат копирани в <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "ГРЕШКА" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Обединяване на контакти", "SUBMIT": " Обединяване на контакти",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Съобщение", "MESSAGE_BUTTON": "Съобщение",
"SEND_MESSAGE": "Изпрати съобщение", "SEND_MESSAGE": "Изпрати съобщение",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Контакти" "CONTACTS": "Контакти"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Успешно запазване на контакта", "SUCCESS_MESSAGE": "Успешно запазване на контакта",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Внасяне на контакти чрез CSV файл.", "DESCRIPTION": "Внасяне на контакти чрез CSV файл.",

View File

@ -117,6 +117,7 @@
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread", "MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Reopen conversation", "REOPEN": "Reopen conversation",
"SNOOZE": { "SNOOZE": {
"TITLE": "Snooze", "TITLE": "Snooze",

View File

@ -300,6 +300,12 @@
"TITLE": "Unlink", "TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully", "SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again" "ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Отмени"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Изтрий",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -35,6 +35,24 @@
} }
} }
}, },
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature", "TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.", "NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note", "SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply", "SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Les dades de contacte de <strong>{primaryContactName}</strong> es copiaran a <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "Les dades de contacte de <strong>{primaryContactName}</strong> es copiaran a <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "ERROR_MESSAGE" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Fusiona contactes", "SUBMIT": " Fusiona contactes",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Missatge", "MESSAGE_BUTTON": "Missatge",
"SEND_MESSAGE": "Envia missatge", "SEND_MESSAGE": "Envia missatge",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Contactes" "CONTACTS": "Contactes"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Contacte guardat correctament", "SUCCESS_MESSAGE": "Contacte guardat correctament",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "Aquest contacte s'ha desbloquejat correctament",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Importa contactes a través d'un fitxer CSV.", "DESCRIPTION": "Importa contactes a través d'un fitxer CSV.",

View File

@ -117,6 +117,7 @@
"PENDING": "Marca com a pendent", "PENDING": "Marca com a pendent",
"RESOLVED": "Marca com a resolt", "RESOLVED": "Marca com a resolt",
"MARK_AS_UNREAD": "Marca com a no llegit", "MARK_AS_UNREAD": "Marca com a no llegit",
"MARK_AS_READ": "Marca com a llegit",
"REOPEN": "Torna a obrir la conversa", "REOPEN": "Torna a obrir la conversa",
"SNOOZE": { "SNOOZE": {
"TITLE": "Posposat", "TITLE": "Posposat",

View File

@ -300,6 +300,12 @@
"TITLE": "Desenllaça", "TITLE": "Desenllaça",
"SUCCESS": "S'ha desenllaçat la issue correctament", "SUCCESS": "S'ha desenllaçat la issue correctament",
"ERROR": "S'ha produït un error en desenllaçar la issue, torna-ho a provar" "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"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "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": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -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": "Per defecte",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Signatura personal del missatge", "TITLE": "Signatura personal del missatge",
"NOTE": "Crea una signatura de missatge única per aparèixer al final de cada missatge que envieu des de qualsevol safata d'entrada. També pots incloure una imatge en línia, que és compatible amb el xat en directe, el correu electrònic i les bústies d'entrada de l'API.", "NOTE": "Crea una signatura de missatge única per aparèixer al final de cada missatge que envieu des de qualsevol safata d'entrada. També pots incloure una imatge en línia, que és compatible amb el xat en directe, el correu electrònic i les bústies d'entrada de l'API.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Canvia a la nota privada", "SWITCH_TO_PRIVATE_NOTE": "Canvia a la nota privada",
"SWITCH_TO_REPLY": "Canvia a Respon", "SWITCH_TO_REPLY": "Canvia a Respon",
"TOGGLE_SNOOZE_DROPDOWN": "Commuta el menú desplegable de posposar" "TOGGLE_SNOOZE_DROPDOWN": "Commuta el menú desplegable de posposar"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "ERROR_MESSAGE" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Merge contacts", "SUBMIT": " Merge contacts",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Zpráva", "MESSAGE_BUTTON": "Zpráva",
"SEND_MESSAGE": "Send message", "SEND_MESSAGE": "Send message",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Kontakty" "CONTACTS": "Kontakty"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Kontakt byl úspěšně uložen", "SUCCESS_MESSAGE": "Kontakt byl úspěšně uložen",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.", "DESCRIPTION": "Import contacts through a CSV file.",

View File

@ -117,6 +117,7 @@
"PENDING": "Označit jako nevyřízené", "PENDING": "Označit jako nevyřízené",
"RESOLVED": "Označit jako vyřešené", "RESOLVED": "Označit jako vyřešené",
"MARK_AS_UNREAD": "Mark as unread", "MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Znovu otevřít konverzaci", "REOPEN": "Znovu otevřít konverzaci",
"SNOOZE": { "SNOOZE": {
"TITLE": "Odložit", "TITLE": "Odložit",

View File

@ -300,6 +300,12 @@
"TITLE": "Unlink", "TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully", "SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again" "ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Zrušit"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Vymazat",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -35,6 +35,24 @@
} }
} }
}, },
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature", "TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.", "NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note", "SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply", "SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -35,7 +35,7 @@
}, },
"API": { "API": {
"SUCCESS_MESSAGE": "Canned response added successfully.", "SUCCESS_MESSAGE": "Canned response added successfully.",
"ERROR_MESSAGE": "Kunne ikke oprette forbindelse til Woot Server, Prøv igen senere." "ERROR_MESSAGE": "Kunne ikke oprette forbindelse til Woot server, Prøv igen senere."
} }
}, },
"EDIT": { "EDIT": {

View File

@ -9,7 +9,7 @@
"FAILED_TO_SEND": "Failed to send", "FAILED_TO_SEND": "Failed to send",
"TAB_HEADING": "Samtaler", "TAB_HEADING": "Samtaler",
"MENTION_HEADING": "Omtaler", "MENTION_HEADING": "Omtaler",
"UNATTENDED_HEADING": "Unattet", "UNATTENDED_HEADING": "Ubehandlet",
"SEARCH": { "SEARCH": {
"INPUT": "Søg efter Mennesker, Chats, Gemte svar .." "INPUT": "Søg efter Mennesker, Chats, Gemte svar .."
}, },

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Kontaktoplysninger på <strong>{primaryContactName}</strong> vil blive kopieret til <strong>{parentContactName}</strong>." "ATTRIBUTE_WARNING": "Kontaktoplysninger på <strong>{primaryContactName}</strong> vil blive kopieret til <strong>{parentContactName}</strong>."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "FEJL_MEDDELELSE" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Sammenflet kontakter", "SUBMIT": " Sammenflet kontakter",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Besked", "MESSAGE_BUTTON": "Besked",
"SEND_MESSAGE": "Send besked", "SEND_MESSAGE": "Send besked",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Kontakter" "CONTACTS": "Kontakter"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Kontakt gemt med succes", "SUCCESS_MESSAGE": "Kontakt gemt med succes",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Importér kontakter via en CSV-fil.", "DESCRIPTION": "Importér kontakter via en CSV-fil.",

View File

@ -117,6 +117,7 @@
"PENDING": "Markér som afventende", "PENDING": "Markér som afventende",
"RESOLVED": "Marker som løst", "RESOLVED": "Marker som løst",
"MARK_AS_UNREAD": "Marker som ulæst", "MARK_AS_UNREAD": "Marker som ulæst",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Genåbn samtale", "REOPEN": "Genåbn samtale",
"SNOOZE": { "SNOOZE": {
"TITLE": "Udsæt", "TITLE": "Udsæt",

View File

@ -300,6 +300,12 @@
"TITLE": "Unlink", "TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully", "SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again" "ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Annuller"
} }
} }
}, },
@ -433,6 +439,20 @@
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
}, },
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Slet",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the FAQ?", "TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "", "DESCRIPTION": "",

View File

@ -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": "Standard",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": { "MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personlig beskedsignatur", "TITLE": "Personlig beskedsignatur",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.", "NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Skift til privat note", "SWITCH_TO_PRIVATE_NOTE": "Skift til privat note",
"SWITCH_TO_REPLY": "Skift til svar", "SWITCH_TO_REPLY": "Skift til svar",
"TOGGLE_SNOOZE_DROPDOWN": "Skift snooze dropdown" "TOGGLE_SNOOZE_DROPDOWN": "Skift snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Vind / ¤",
"ALT_OR_OPTION_KEY": "Alt. / ¤",
"FORWARD_SLASH_KEY": "/"
} }
} }
} }

View File

@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Details von Kontakt <strong>{primaryContactName}</strong> wird zu <strong>{parentContactName}</strong> kopiert." "ATTRIBUTE_WARNING": "Details von Kontakt <strong>{primaryContactName}</strong> wird zu <strong>{parentContactName}</strong> kopiert."
}, },
"SEARCH": { "SEARCH": {
"ERROR": "ERROR_MESSAGE" "ERROR_MESSAGE": "Something went wrong. Please try again later."
}, },
"FORM": { "FORM": {
"SUBMIT": " Kontakte zusammenführen", "SUBMIT": " Kontakte zusammenführen",
@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Nachricht", "MESSAGE_BUTTON": "Nachricht",
"SEND_MESSAGE": "Nachricht senden", "SEND_MESSAGE": "Nachricht senden",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": { "BREADCRUMB": {
"CONTACTS": "Kontakte" "CONTACTS": "Kontakte"
}, },
@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Kontakt erfolgreich gespeichert", "SUCCESS_MESSAGE": "Kontakt erfolgreich gespeichert",
"ERROR_MESSAGE": "Unable to save contact. Please try again later." "ERROR_MESSAGE": "Unable to save contact. Please try again later."
}, },
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "Dieser Kontakt wurde erfolgreich entsperrt",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": { "IMPORT_CONTACT": {
"TITLE": "Import contacts", "TITLE": "Import contacts",
"DESCRIPTION": "Kontakte über CSV-Datei importieren.", "DESCRIPTION": "Kontakte über CSV-Datei importieren.",

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