Compare commits

..
Author SHA1 Message Date
Shivam Mishra 6f2e5abdda fix: don't check for source id 2025-03-03 15:54:02 +05:30
488c334b72 chore: Update translations (#11002)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-03-03 14:27:33 +05:30
Sivin VargheseandGitHub 7c2ca1a07e fix: Not using saved order_by when fetching conversation list (#11008)
# Pull Request Template

## Description

This PR fixes the issue where the saved `order_by` value for the
conversation list is not being applied. The feature was originally added
in PR https://github.com/chatwoot/chatwoot/pull/8237 but broke after
merging the Vue 3 migration PR
https://github.com/chatwoot/chatwoot/pull/10047

Fixes
https://linear.app/chatwoot/issue/CW-4110/not-using-the-saved-sort-order-by-option-from-ui-settings

**Cause of the Issue:**  
The previous implementation checked `orderBy` against the keys of the
sorting constants instead of their values. Since `orderBy` stores a
sorting value, this caused the condition to fail, leading to fallback to
the default sorting option.

**Solution:**  
The fix ensures that `orderBy` is validated against the values of the
sorting constants rather than the keys. This correctly applies the saved
sorting preference, while still falling back to the default if needed.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Loom video**

https://www.loom.com/share/ebe8a4d3f1c041c6862334dc3b6d43a3?sid=5167feb7-eb4a-4f2c-8211-662830ba946c




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-03-03 14:22:27 +05:30
Muhsin KelothandGitHub bca8c99148 feat: Add June events for linear integration (#11007)
Fixes https://linear.app/chatwoot/issue/CW-3415/june-sdk-events
2025-03-03 13:33:11 +05:30
PranavandGitHub ecfa6bf6a2 feat: Add support for account abuse detection (#11001)
This PR adds service to automate account abuse detection. Currently
based on the signup name and URL, could potentially add more context
such as usage analysis, message metadata etc.
2025-02-28 15:28:19 -08:00
Vishnu NarayananandGitHub 616bbced9c feat: allow copilot use without connecting an inbox (#10992)
This PR allows Copilot to be used without connecting the Captain assistant to an inbox. Currently, if Captain is enabled on an account, it takes over conversations and responds directly to users. This PR enables the use of Captain as a Copilot without allowing it to respond to users. Additionally, it allows using a different assistant for Copilot instead of the default Captain assistant.

The selection logic for the Copilot assistant follows this order of preference:

- If the user has selected a specific assistant, it takes first preference for Copilot.
- If the above is not available, the assistant connected to the inbox takes preference.
- If neither of the above is available, the first assistant in the account takes preference.
2025-02-28 15:20:39 -08:00
PranavandGitHub 24f49b9b5a fix: Process non-image inline attachments as regular attachments (#10998)
In Chatwoot, we rely on the Content-ID for inline attachments to replace
the link with the uploaded attachment URL. Our expectation was that only
images would be inline, while other attachments would not. However,
email clients like Apple Mail (sigh) allow users to send inline
attachments that are not images, and these attachments often lack a
Content-ID. This creates significant issues in rendering.
 
I investigated how other email clients handle this scenario. When
viewing the same email (sent from Apple Mail) in Gmail, only one image
appears—and it’s treated as an attachment, not inline. This happens
because both attachments are the same image, and Apple Mail only sends
one copy. See the screenshot below.

| Apple Mail | Gmail | 
| -- | -- | 
| <img width="646" alt="Screenshot 2025-02-27 at 8 20 17 PM"
src="https://github.com/user-attachments/assets/e0d1cd2d-e47c-4081-a53b-7a67106341b3"
/> | <img width="360" alt="Screenshot 2025-02-27 at 8 20 51 PM"
src="https://github.com/user-attachments/assets/b206e56e-8f86-43e9-867b-d895c36aff78"
/> |

A good fix for this would be to check if the Content-ID is missing and
then upload the file as a regular attachment. However, the Mail gem (for
some reason) automatically adds a default Content-ID to inline parts. I
need to dig into the source code to understand why this happens.

For now, I’ve implemented a check to treat non-image attachments as
regular attachments. Inline image attachments are already handled by
appending an image tag at the end if the content-id is not found in the
body. A sample conversation to test this behavior is
[here](https://app.chatwoot.com/app/accounts/1/conversations/46732).
2025-02-28 13:33:48 -08:00
Muhsin KelothandGitHub ef24eef0b9 feat: move Linear config to installation_config (#10999)
This PR has the following changes

- Add `LINEAR_CLIENT_ID` and `LINEAR_CLIENT_SECRET` to installation
config
- Add Linear config to super_admin/features.yml
- Replace usage of ENV.fetch with GlobalConfigService.load for fetch
Client Id and Secret.
2025-02-28 14:20:27 +05:30
Sojan e90c231dd3 Merge branch 'release/4.0.3' into develop 2025-02-27 21:05:51 -08:00
Sojan 100d39d58a Bump version to 4.0.3 2025-02-27 21:03:31 -08:00
Chatwoot BotandGitHub f57c9279aa chore: Update translations (#10997)
- Update translations
2025-02-27 20:56:42 -08:00
6eecd84b22 feat: Add support for bulk action for Captain FAQs (#10905)
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-02-27 17:05:33 -08:00
Sojan JoseandGitHub a8febc00d3 chore: Add Swagger endpoint for updating custom attributes (#10995)
- Add swagger endpoint for updating custom attributes in a conversation
2025-02-27 16:57:25 -08:00
PranavandGitHub 80c87da8c9 feat: Add new APIs for live reports with team filter (#10994)
This PR is part of the larger #10849 implementation and introduces a new
Live Reports API to provide real-time conversation metrics.

The /live_reports/conversation_metrics endpoint returns account-level or
team-level conversation statistics, including open, pending, unattended,
and unassigned conversation counts.

The /live_reports/grouped_conversation_metrics endpoint accepts a group
parameter, either team_id or assignee_id, and returns open and
unattended conversation counts based on the specified grouping.
2025-02-27 16:11:04 -08:00
Chatwoot BotandGitHub 0fbb9b91b2 chore: Update translations (#10990) 2025-02-27 15:46:02 -08:00
Sivin VargheseandGitHub b52b2b9a47 chore: Fix issue with compose conversation form (#10991) 2025-02-27 21:45:20 +05:30
12134f9391 feat: Linear OAuth 2.0 (#10851)
Fixes https://linear.app/chatwoot/issue/CW-3417/oauth-20-authentication
We are planning to publish the Chatwoot app in the Linear [integration
list](https://linear.app/docs/integration-directory). While we currently
use token-based authentication, Linear recommends OAuth2 authentication.
This PR implements OAuth2 support.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-02-27 18:15:53 +05:30
Sivin VargheseandGitHub 30996140a3 fix: Show "not-allowed" cursor for disabled buttons (#10986)
# Pull Request Template

## Description

Fixed an issue where hover styles were still applied to disabled
buttons. Previously, `disabled:pointer-events-none` was used to prevent
interactions, ensuring hover styles wouldn’t apply. However, this also
removed showing the "not-allowed" cursor. This has been replaced with
`hover:enabled` to ensure hover effects only apply when the button is
enabled while preserving proper cursor behavior.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### **Screen recording**

**Before**


https://github.com/user-attachments/assets/a5d33869-4d9d-499b-94c8-6371cbb44ecc


**After**


https://github.com/user-attachments/assets/a03d93dc-2374-4c76-8bed-8f1fe79593d2


**Case where `disabled:pointer-events-none` and `hover:enabled` are not
added (for context only).**



https://github.com/user-attachments/assets/407320d8-f889-49d2-a78c-a5d24ed745a0




## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-02-27 17:54:40 +05:30
77a0f18685 feat: setup eslint for vue-i18n (#10889)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-02-27 16:53:57 +05:30
Sivin VargheseandGitHub 2556de1f38 feat: Support bigger font size in dashboard (#10974)
# Pull Request Template

## Description

Fixes
https://linear.app/chatwoot/issue/CW-4091/accessibility-improvement-support-bigger-font-size-for-the-dashboard

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### **Loom video**

https://www.loom.com/share/1ab781859fa748a5ad54aacbacd127b4?sid=a7dd9164-a6de-462f-bff7-1b25e9c55b4f

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-02-27 12:10:33 +05:30
Sojan JoseandGitHub a9de672932 chore: Fix issue with new conversation editor (#10985)
https://github.com/chatwoot/chatwoot/pull/10888 introduced a bug in the
conversation editor since we were using the templates , email and phone
number payloads in contactable inboxes . temporary fix for this, we need
to rework this fix.
2025-02-26 16:35:47 -08:00
Sivin VargheseandGitHub 0ccbe5882c chore: Improvement is keyboard shortcuts (#10925) 2025-02-26 14:51:55 +05:30
544 changed files with 8868 additions and 3288 deletions
+14
View File
@@ -4,6 +4,8 @@ module.exports = {
'prettier',
'plugin:vue/vue3-recommended',
'plugin:vitest-globals/recommended',
// use recommended-legacy when upgrading the plugin to v4
'plugin:@intlify/vue-i18n/recommended',
],
overrides: [
{
@@ -229,6 +231,18 @@ module.exports = {
'vue/singleline-html-element-content-newline': 'off',
'import/extensions': ['off'],
'no-console': 'error',
'@intlify/vue-i18n/no-dynamic-keys': 'warn',
'@intlify/vue-i18n/no-unused-keys': [
'warn',
{
extensions: ['.js', '.vue'],
},
],
},
settings: {
'vue-i18n': {
localeDir: './app/javascript/*/i18n/**.json',
},
},
env: {
browser: true,
+1
View File
@@ -73,6 +73,7 @@ test/cypress/videos/*
#ignore files under .vscode directory
.vscode
.cursor
# yalc for local testing
.yalc
@@ -1,5 +1,11 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
def teams
teams = linear_processor_service.teams
@@ -90,4 +96,8 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end
end
@@ -0,0 +1,64 @@
class Api::V2::Accounts::LiveReportsController < Api::V1::Accounts::BaseController
before_action :load_conversations, only: [:conversation_metrics, :grouped_conversation_metrics]
before_action :set_group_scope, only: [:grouped_conversation_metrics]
before_action :check_authorization
def conversation_metrics
render json: {
open: @conversations.open.count,
unattended: @conversations.open.unattended.count,
unassigned: @conversations.open.unassigned.count,
pending: @conversations.pending.count
}
end
def grouped_conversation_metrics
count_by_group = @conversations.open.group(@group_scope).count
unattended_by_group = @conversations.open.unattended.group(@group_scope).count
unassigned_by_group = @conversations.open.unassigned.group(@group_scope).count
group_metrics = count_by_group.map do |group_id, count|
metric = {
open: count,
unattended: unattended_by_group[group_id] || 0,
unassigned: unassigned_by_group[group_id] || 0
}
metric[@group_scope] = group_id
metric
end
render json: group_metrics
end
private
def check_authorization
authorize :report, :view?
end
def set_group_scope
render json: { error: 'invalid group_by' }, status: :unprocessable_entity and return unless %w[
team_id
assignee_id
].include?(permitted_params[:group_by])
@group_scope = permitted_params[:group_by]
end
def team
return unless permitted_params[:team_id]
@team ||= Current.account.teams.find(permitted_params[:team_id])
end
def load_conversations
scope = Current.account.conversations
scope = scope.where(team_id: team.id) if team.present?
@conversations = scope
end
def permitted_params
params.permit(:team_id, :group_by)
end
end
@@ -0,0 +1,73 @@
class Linear::CallbacksController < ApplicationController
include Linear::IntegrationHelper
def show
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/linear/callback"
)
handle_response
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
end
private
def oauth_client
app_id = GlobalConfigService.load('LINEAR_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
OAuth2::Client.new(
app_id,
app_secret,
{
site: 'https://api.linear.app',
token_url: '/oauth/token',
authorize_url: '/oauth/authorize'
}
)
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'linear',
settings: {
token_type: parsed_body['token_type'],
expires_in: parsed_body['expires_in'],
scope: parsed_body['scope']
}
)
# You may wonder why we're not handling the refresh token update, since the token will expire only after 10 years, https://github.com/linear/linear/issues/251
hook.save!
redirect_to linear_redirect_uri
rescue StandardError => e
Rails.logger.error("Linear callback error: #{e.message}")
redirect_to linear_redirect_uri
end
def account
@account ||= Account.find(account_id)
end
def account_id
return unless params[:state]
verify_linear_token(params[:state])
end
def linear_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/linear"
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -39,6 +39,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
+47
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 ||= GlobalConfigService.load('LINEAR_CLIENT_SECRET', nil)
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Linear token: #{e.message}")
nil
end
end
+9 -1
View File
@@ -14,6 +14,7 @@ import WootSnackbarBox from './components/SnackbarContainer.vue';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -37,8 +38,15 @@ export default {
const router = useRouter();
const store = useStore();
const { accountId } = useAccount();
// Use the font size composable (it automatically sets up the watcher)
const { currentFontSize } = useFontSize();
return { router, store, currentAccountId: accountId };
return {
router,
store,
currentAccountId: accountId,
currentFontSize,
};
},
data() {
return {
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class CaptainBulkActionsAPI extends ApiClient {
constructor() {
super('captain/bulk_actions', { accountScoped: true });
}
}
export default new CaptainBulkActionsAPI();
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
}
export default new ConversationApi();
@@ -82,7 +82,7 @@ input[type='url']:not(.reset-base) {
}
input[type='file'] {
@apply bg-white dark:bg-slate-800 leading-[1.15] mb-4;
@apply bg-white dark:bg-n-solid-1 leading-[1.15] mb-4;
}
// Select
@@ -141,11 +141,16 @@ code {
@apply text-xs border-0;
&.hljs {
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-50 rounded-lg p-5;
@apply bg-n-slate-3 dark:bg-n-solid-3 text-slate-800 dark:text-slate-50 rounded-lg p-5;
.hljs-number,
.hljs-string {
@apply text-red-800 dark:text-red-400;
}
.hljs-name,
.hljs-tag {
@apply text-n-slate-11;
}
}
}
@@ -22,7 +22,7 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
@@ -44,7 +44,7 @@ const handleButtonClick = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -40,7 +40,7 @@ const handleSubmit = campaignDetails => {
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.LIVE_CHAT.CREATE.TITLE`) }}
@@ -39,7 +39,7 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[400px] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
@@ -4,6 +4,10 @@ defineProps({
type: String,
default: 'col',
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
@@ -18,10 +22,11 @@ const handleClick = () => {
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 px-6 py-5"
:class="
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
"
class="flex w-full gap-3 py-5"
:class="[
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
selectable ? 'px-10 py-6' : 'px-6',
]"
@click="handleClick"
>
<slot />
@@ -65,8 +65,8 @@ const toggleBlock = () => {
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
>
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full mx-auto max-w-[650px]">
<header class="sticky top-0 z-10 px-6 3xl:px-0">
<div class="w-full mx-auto max-w-[40.625rem]">
<div class="flex items-center justify-between w-full h-20 gap-2">
<Breadcrumb
:items="breadcrumbItems"
@@ -98,8 +98,8 @@ const toggleBlock = () => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-px">
<div class="w-full py-4 mx-auto max-w-[650px]">
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot name="default" />
</div>
</main>
@@ -107,7 +107,7 @@ const toggleBlock = () => {
<div
v-if="slots.sidebar"
class="overflow-y-auto justify-end min-w-[200px] w-full py-6 max-w-[440px] border-l border-n-weak bg-n-solid-2"
class="overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<slot name="sidebar" />
</div>
@@ -60,7 +60,7 @@ const emit = defineEmits([
<template>
<header class="sticky top-0 z-10">
<div
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[960px]"
class="flex items-center justify-between w-full h-20 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6"
class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -72,7 +72,7 @@ const openFilter = () => {
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
@@ -22,7 +22,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[960px] mx-auto overflow-hidden h-full max-h-[448px]"
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
@@ -60,7 +60,7 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[960px] mx-auto lg:px-6">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -96,7 +96,7 @@ const togglePortalSwitcher = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[960px] mx-auto py-3 lg:px-6">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
@@ -52,7 +52,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col absolute w-[400px] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
class="flex flex-col absolute w-[25rem] bg-n-alpha-3 outline outline-1 outline-n-container backdrop-blur-[100px] shadow-lg gap-6 rounded-xl p-6"
>
<div class="flex items-center justify-between">
<h3>
@@ -75,7 +75,7 @@ onMounted(() => {
<div>
<div class="flex justify-between w-full gap-4 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[100px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[6.25rem] text-slate-900 dark:text-slate-50"
>
{{
t(
@@ -90,9 +90,9 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_DESCRIPTION_PLACEHOLDER'
)
"
class="w-[220px]"
class="w-[13.75rem]"
custom-text-area-wrapper-class="!p-0 !border-0 !rounded-none !bg-transparent transition-none"
custom-text-area-class="max-h-[150px]"
custom-text-area-class="max-h-[9.375rem]"
auto-height
min-height="3rem"
/>
@@ -108,12 +108,12 @@ onMounted(() => {
:label="
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TITLE')
"
custom-label-class="min-w-[120px]"
custom-label-class="min-w-[7.5rem]"
/>
</div>
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
class="text-sm font-medium whitespace-nowrap min-w-[7.5rem] text-slate-900 dark:text-slate-50"
>
{{
t('HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS')
@@ -126,7 +126,7 @@ onMounted(() => {
'HELP_CENTER.EDIT_ARTICLE_PAGE.ARTICLE_PROPERTIES.META_TAGS_PLACEHOLDER'
)
"
class="w-[224px]"
class="w-[14rem]"
/>
</div>
</div>
@@ -90,7 +90,7 @@ const handleCategory = async formData => {
<template>
<div
class="w-[400px] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
class="w-[25rem] absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{
@@ -201,7 +201,7 @@ defineExpose({ state, isSubmitDisabled });
color="slate"
size="sm"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[38px] !w-[38px] absolute top-[31px] !outline-none !rounded-[7px] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
@@ -74,7 +74,7 @@ const handleDeletePortal = () => {
</div>
<div
v-else-if="activePortal"
class="flex flex-col w-full gap-4 max-w-[640px] pb-8"
class="flex flex-col w-full gap-4 max-w-[40rem] pb-8"
>
<PortalBaseSettings
:active-portal="activePortal"
@@ -92,7 +92,7 @@ const redirectToPortalHomePage = () => {
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[440px] rounded-xl shadow-md flex flex-col gap-4"
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
@@ -13,6 +13,7 @@ import {
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -47,6 +48,7 @@ const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const messageSignature = useMapGetter('getMessageSignature');
const inboxesList = useMapGetter('inboxes/getInboxes');
const sendWithSignature = computed(() =>
fetchSignatureFlagFromUISettings(targetInbox.value?.channelType)
@@ -104,7 +106,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
// Merge the processed contactableInboxes with the inboxesList
selectedContact.value.contactInboxes = mergeInboxDetails(
contactableInboxes,
inboxesList.value
);
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
@@ -162,9 +169,12 @@ watch(
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(contactInboxes);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
},
@@ -148,7 +148,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<WhatsAppOptions
@@ -47,10 +47,10 @@ const removeAttachment = id => {
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[72px] h-[72px]"
class="relative group/image w-[4.5rem] h-[4.5rem]"
>
<img
class="object-cover w-[72px] h-[72px] rounded-lg"
class="object-cover w-[4.5rem] h-[4.5rem] rounded-lg"
:src="attachment.thumb"
/>
<Button
@@ -69,7 +69,7 @@ const removeAttachment = id => {
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
class="max-w-[18.75rem] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[42rem] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
@@ -87,6 +87,21 @@ export const processContactableInboxes = inboxes => {
}));
};
export const mergeInboxDetails = (inboxesData, inboxesList = []) => {
if (!inboxesData || !inboxesData.length) {
return [];
}
return inboxesData.map(inboxData => {
const matchingInbox =
inboxesList.find(inbox => inbox.id === inboxData.id) || {};
return {
...camelcaseKeys(matchingInbox, { deep: true }),
...inboxData,
};
});
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
@@ -110,6 +110,153 @@ describe('composeConversationHelper', () => {
});
});
describe('mergeInboxDetails', () => {
it('returns empty array if inboxesData is empty or null', () => {
expect(helpers.mergeInboxDetails(null)).toEqual([]);
expect(helpers.mergeInboxDetails([])).toEqual([]);
expect(helpers.mergeInboxDetails(undefined)).toEqual([]);
});
it('merges inbox data with matching inboxes from the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 2, sourceId: 'source2' },
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
channel_id: 10,
phone_number: null,
},
{
id: 2,
name: 'Inbox 2',
channel_type: 'Channel::Whatsapp',
channel_id: 20,
phone_number: '+1234567890',
},
{
id: 3,
name: 'Inbox 3',
channel_type: 'Channel::Api',
channel_id: 30,
phone_number: null,
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
channelId: 10,
phoneNumber: null,
});
expect(result[1]).toMatchObject({
id: 2,
sourceId: 'source2',
name: 'Inbox 2',
channelType: 'Channel::Whatsapp',
channelId: 20,
phoneNumber: '+1234567890',
});
});
it('handles inboxes not found in the list', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1' },
{ id: 99, sourceId: 'source99' }, // This doesn't exist in inboxesList
];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result.length).toBe(2);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
});
expect(result[1]).toMatchObject({
id: 99,
sourceId: 'source99',
});
expect(result[1].name).toBeUndefined();
expect(result[1].channelType).toBeUndefined();
});
it('camelcases properties from inboxesList', () => {
const inboxesData = [{ id: 1, sourceId: 'source1' }];
const inboxesList = [
{
id: 1,
name: 'Inbox 1',
channel_type: 'Channel::Email',
avatar_url: 'https://example.com/avatar.png',
working_hours: [
{
day_of_week: 1,
closed_all_day: false,
},
],
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0]).toMatchObject({
id: 1,
sourceId: 'source1',
name: 'Inbox 1',
channelType: 'Channel::Email',
avatarUrl: 'https://example.com/avatar.png',
});
expect(result[0].workingHours[0]).toMatchObject({
dayOfWeek: 1,
closedAllDay: false,
});
});
it('preserves original properties when they conflict with inboxesList', () => {
const inboxesData = [
{ id: 1, sourceId: 'source1', name: 'Original Name' },
];
const inboxesList = [
{
id: 1,
name: 'List Name',
channel_type: 'Channel::Email',
},
];
const result = helpers.mergeInboxDetails(inboxesData, inboxesList);
expect(result[0].name).toBe('Original Name');
expect(result[0].channelType).toBe('Channel::Email');
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
@@ -99,45 +99,50 @@ const computedJustify = computed(() => {
const STYLE_CONFIG = {
colors: {
blue: {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
solid:
'bg-n-brand text-white hover:enabled:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-text hover:bg-n-brand/20 outline-transparent',
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-blue-border',
ghost: 'text-n-blue-text hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:underline outline-transparent',
ghost: 'text-n-blue-text hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
solid:
'bg-n-ruby-9 text-white hover:enabled:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:underline outline-transparent',
'bg-n-ruby-9/10 text-n-ruby-11 hover:enabled:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:enabled:bg-n-ruby-9/10 outline-n-ruby-8',
ghost: 'text-n-ruby-11 hover:enabled:bg-n-alpha-2 outline-transparent',
link: 'text-n-ruby-9 hover:enabled:underline outline-transparent',
},
amber: {
solid: 'bg-n-amber-9 text-white hover:bg-n-amber-10 outline-transparent',
solid:
'bg-n-amber-9 text-white hover:enabled:bg-n-amber-10 outline-transparent',
faded:
'bg-n-amber-9/10 text-n-slate-12 hover:bg-n-amber-9/20 outline-transparent',
outline: 'text-n-amber-11 hover:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:underline outline-transparent',
ghost: 'text-n-amber-9 hover:bg-n-alpha-2 outline-transparent',
'bg-n-amber-9/10 text-n-slate-12 hover:enabled:bg-n-amber-9/20 outline-transparent',
outline:
'text-n-amber-11 hover:enabled:bg-n-amber-9/10 outline-n-amber-9',
link: 'text-n-amber-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-amber-9 hover:enabled:bg-n-alpha-2 outline-transparent',
},
slate: {
solid:
'bg-n-solid-3 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 hover:enabled:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:text-n-slate-12 hover:underline outline-transparent',
ghost: 'text-n-slate-12 hover:bg-n-alpha-2 outline-transparent',
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 outline-transparent',
outline: 'text-n-slate-11 outline-n-strong hover:enabled:bg-n-slate-9/10',
link: 'text-n-slate-11 hover:enabled:text-n-slate-12 hover:enabled:underline outline-transparent',
ghost: 'text-n-slate-12 hover:enabled:bg-n-alpha-2 outline-transparent',
},
teal: {
solid: 'bg-n-teal-9 text-white hover:bg-n-teal-10 outline-transparent',
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-slate-12 hover:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:underline outline-transparent',
ghost: 'text-n-teal-9 hover:bg-n-alpha-2 outline-transparent',
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 outline-transparent',
outline: 'text-n-teal-11 hover:enabled:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline outline-transparent',
ghost: 'text-n-teal-9 hover:enabled:bg-n-alpha-2 outline-transparent',
},
},
sizes: {
@@ -171,7 +176,7 @@ const STYLE_CONFIG = {
center: 'justify-center',
end: 'justify-end',
},
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50',
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
};
const variantClasses = computed(() => {
@@ -68,7 +68,7 @@ const handlePageChange = event => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<div class="w-full max-w-[960px] mx-auto">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
@@ -96,7 +96,7 @@ const handlePageChange = event => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<div class="w-full max-w-[960px] mx-auto py-4">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
@@ -46,14 +47,27 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'navigate']);
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const statusAction = computed(() => {
if (props.status === 'pending') {
return [
@@ -102,8 +116,17 @@ const handleDocumentableClick = () => {
</script>
<template>
<CardLayout :class="{ 'rounded-md': compact }">
<div class="flex justify-between w-full gap-1">
<CardLayout
selectable
class="relative"
:class="{ 'rounded-md': compact }"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ question }}
</span>
@@ -148,7 +171,7 @@ const handleDocumentableClick = () => {
v-if="documentable.type === 'Captain::Document'"
class="inline-flex items-center gap-1 truncate over"
>
<i class="i-ph-chat-circle-dots text-base" />
<i class="i-ph-files-light text-base" />
<span class="max-w-96 truncate" :title="documentable.name">
{{ documentable.name }}
</span>
@@ -0,0 +1,59 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
type: {
type: String,
required: true,
},
bulkIds: {
type: Object,
required: true,
},
});
const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
} catch (error) {
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.ERROR_MESSAGE`));
}
};
const handleDialogConfirm = async () => {
await handleBulkDelete(Array.from(props.bulkIds));
bulkDeleteDialogRef.value?.close();
};
defineExpose({ dialogRef: bulkDeleteDialogRef });
</script>
<template>
<Dialog
ref="bulkDeleteDialogRef"
type="alert"
:title="t(`CAPTAIN.${i18nKey}.BULK_DELETE.TITLE`)"
:description="t(`CAPTAIN.${i18nKey}.BULK_DELETE.DESCRIPTION`)"
:confirm-button-label="t(`CAPTAIN.${i18nKey}.BULK_DELETE.CONFIRM`)"
@confirm="handleDialogConfirm"
/>
</template>
@@ -27,7 +27,7 @@ const openBilling = () => {
<template>
<div
class="w-full max-w-[960px] mx-auto h-full max-h-[448px] grid place-content-center"
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
@@ -0,0 +1,47 @@
<script setup>
import Checkbox from './Checkbox.vue';
import { ref } from 'vue';
const defaultValue = ref(false);
const isChecked = ref(false);
const checkedValue = ref(true);
const indeterminateValue = ref(true);
</script>
<template>
<Story title="Components/Checkbox" :layout="{ type: 'grid', width: '250px' }">
<Variant title="States">
<div class="p-2 space-y-4">
<div class="flex items-center justify-between gap-4">
<span>Default:</span>
<Checkbox v-model="defaultValue" />
</div>
<div class="flex items-center justify-between gap-4">
<span>Checked:</span>
<Checkbox v-model="checkedValue" />
</div>
<div class="flex items-center justify-between gap-4">
<span>Indeterminate:</span>
<Checkbox v-model="indeterminateValue" indeterminate />
</div>
<div class="flex items-center justify-between gap-4">
<span>Indeterminate disabled:</span>
<Checkbox v-model="indeterminateValue" indeterminate disabled />
</div>
<div class="flex items-center justify-between gap-4">
<span>Disabled:</span>
<Checkbox v-model="defaultValue" disabled />
</div>
<div class="flex items-center justify-between gap-4">
<span>Disabled Checked:</span>
<Checkbox v-model="isChecked" disabled />
</div>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,63 @@
<script setup>
defineProps({
indeterminate: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['change']);
const modelValue = defineModel('modelValue', {
type: Boolean,
default: false,
});
const handleChange = event => {
modelValue.value = event.target.checked;
emit('change', event);
};
</script>
<template>
<div class="relative w-4 h-4">
<input
:checked="modelValue"
:indeterminate="indeterminate"
type="checkbox"
:disabled="disabled"
class="peer absolute inset-0 z-10 h-4 w-4 disabled:opacity-50 appearance-none rounded border border-n-slate-6 ring-transparent transition-all duration-200 checked:border-n-brand checked:bg-n-brand dark:border-gray-600 dark:checked:border-n-brand indeterminate:border-n-brand indeterminate:bg-n-brand hover:enabled:bg-n-blue-border cursor-pointer"
@change="handleChange"
/>
<!-- Checkmark SVG -->
<svg
viewBox="0 0 14 14"
fill="none"
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-checked:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<path
d="M3 8L6 11L11 3.5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<!-- Minus/Indeterminate SVG -->
<svg
viewBox="0 0 14 14"
fill="none"
class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-indeterminate:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
>
<path
d="M3 7L11 7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</template>
@@ -7,6 +7,7 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from '../icon/Icon.vue';
const props = defineProps({
@@ -26,9 +27,17 @@ const props = defineProps({
type: String,
required: true,
},
assistants: {
type: Array,
default: () => [],
},
activeAssistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'reset']);
const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const COPILOT_USER_ROLES = ['assistant', 'system'];
@@ -97,14 +106,18 @@ watch(
<CopilotLoader v-if="isCaptainTyping" />
</div>
<div>
<div v-if="!messages.length" class="flex-1 px-3 py-3 space-y-1">
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
@@ -112,7 +125,17 @@ watch(
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
<div class="mx-3 mt-px mb-2 flex flex-col items-end flex-1">
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
<button
v-if="messages.length"
class="text-xs flex items-center gap-1 hover:underline"
@@ -121,8 +144,8 @@ watch(
<i class="i-lucide-refresh-ccw" />
<span>{{ $t('CAPTAIN.COPILOT.RESET') }}</span>
</button>
<CopilotInput class="mb-1 flex-1 w-full" @send="sendMessage" />
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const props = defineProps({
assistants: {
type: Array,
required: true,
},
activeAssistant: {
type: Object,
required: true,
},
});
const emit = defineEmits(['setAssistant']);
const { t } = useI18n();
const activeAssistantLabel = computed(() => {
return props.activeAssistant
? props.activeAssistant.name
: t('CAPTAIN.COPILOT.SELECT_ASSISTANT');
});
</script>
<template>
<div>
<DropdownContainer>
<template #trigger="{ toggle, isOpen }">
<Button
:label="activeAssistantLabel"
icon="i-woot-captain"
ghost
slate
xs
:class="{ 'bg-n-alpha-2': isOpen }"
@click="toggle"
/>
</template>
<DropdownBody class="bottom-9 min-w-64 z-50" strong>
<DropdownSection class="max-h-80 overflow-scroll">
<DropdownItem
v-for="assistant in assistants"
:key="assistant.id"
class="!items-start !gap-1 flex-col cursor-pointer"
@click="() => emit('setAssistant', assistant)"
>
<template #label>
<div class="flex gap-1 justify-between w-full">
<div class="items-start flex gap-1 flex-col">
<span class="text-n-slate-12 text-sm">
{{ assistant.name }}
</span>
<span class="line-clamp-2 text-n-slate-11 text-xs">
{{ assistant.description }}
</span>
</div>
<div
v-if="assistant.id === activeAssistant?.id"
class="flex items-center justify-center flex-shrink-0 w-4 h-4 rounded-full bg-n-slate-12 dark:bg-n-slate-11"
>
<i
class="i-lucide-check text-white dark:text-n-slate-1 size-3"
/>
</div>
</div>
</template>
</DropdownItem>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
</div>
</template>
@@ -19,7 +19,7 @@ const beforeClass = computed(() => {
// Add extra blur layer only when strong prop is true, as a hack for Chrome's stacked backdrop-blur limitation
// https://issues.chromium.org/issues/40835530
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
return "before:content-['\x00A0'] before:absolute before:bottom-0 before:left-0 before:w-full before:h-full before:rounded-xl before:backdrop-contrast-70 before:backdrop-blur-sm before:z-0 [&>*]:relative";
});
</script>
@@ -49,7 +49,7 @@ const isSent = computed(() => {
isASmsInbox.value ||
isATelegramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
return status.value === MESSAGE_STATUS.SENT;
}
// All messages will be mark as sent for the Line channel, as there is no source ID.
@@ -67,7 +67,7 @@ const isDelivered = computed(() => {
isASmsInbox.value ||
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
return status.value === MESSAGE_STATUS.DELIVERED;
}
// All messages marked as delivered for the web widget inbox and API inbox once they are sent.
if (isAWebWidgetInbox.value || isAPIInbox.value) {
@@ -88,7 +88,7 @@ const isRead = computed(() => {
isATwilioChannel.value ||
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
return status.value === MESSAGE_STATUS.READ;
}
if (isAWebWidgetInbox.value || isAPIInbox.value) {
@@ -63,7 +63,7 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-12 w-full max-w-[957px] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
@@ -185,7 +185,7 @@ watch(
"
trailing-icon
:disabled="disabled"
class="!h-[30px] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
class="!h-[1.875rem] top-1 !px-2 outline-0 !outline-none !rounded-lg border-0 ltr:!rounded-r-none rtl:!rounded-l-none"
@click="toggleCountryDropdown"
>
<span
@@ -515,7 +515,7 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
class="w-[12.5rem] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -31,7 +31,7 @@ const shouldRenderComponent = computed(() => {
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[151px] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
:class="{
'n-blue-text bg-n-alpha-2 active': active,
}"
@@ -27,7 +27,7 @@ const updateValue = () => {
>
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span
class="absolute top-px left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
class="absolute top-[0.07rem] left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
:class="
modelValue
? 'translate-x-2.5 bg-white'
@@ -353,10 +353,11 @@ function setFiltersFromUISettings() {
const { conversations_filter_by: filterBy = {} } = uiSettings.value;
const { status, order_by: orderBy } = filterBy;
activeStatus.value = status || wootConstants.STATUS_TYPE.OPEN;
activeSortBy.value =
Object.keys(wootConstants.SORT_BY_TYPE).find(
sortField => sortField === orderBy
) || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
activeSortBy.value = Object.values(wootConstants.SORT_BY_TYPE).includes(
orderBy
)
? orderBy
: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
}
function emitConversationLoaded() {
@@ -23,33 +23,34 @@ export default {
<template>
<div
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row"
class="ml-0 mr-0 py-8 w-full"
:class="{
'border-b border-solid border-slate-50 dark:border-slate-700/30':
showBorder,
'border-b border-solid border-n-weak/60 dark:border-n-weak': showBorder,
}"
>
<div class="w-full xl:w-1/4 min-w-0 xl:max-w-[30%] pr-12">
<p
v-if="title"
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
>
{{ title }}
</p>
<p
class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2"
>
<slot v-if="subTitle" name="subTitle">
{{ subTitle }}
</slot>
</p>
<p v-if="note">
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
{{ note }}
</p>
</div>
<div class="w-full xl:w-1/2 min-w-0 xl:max-w-[50%]">
<slot />
<div class="grid grid-cols-1 lg:grid-cols-6 gap-6">
<div class="col-span-2 xl:col-span-1">
<p
v-if="title"
class="text-base text-woot-500 dark:text-woot-500 mb-0 font-medium"
>
{{ title }}
</p>
<p
class="text-sm mb-2 text-slate-700 dark:text-slate-300 leading-5 tracking-normal mt-2"
>
<slot v-if="subTitle" name="subTitle">
{{ subTitle }}
</slot>
</p>
<p v-if="note">
<span class="font-semibold">{{ $t('INBOX_MGMT.NOTE') }}</span>
{{ note }}
</p>
</div>
<div class="col-span-4 lg:col-span-4 2xl:col-span-3">
<slot />
</div>
</div>
</div>
</template>
@@ -1,8 +1,11 @@
<script setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { ref } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
conversationId: {
type: [Number, String],
@@ -13,10 +16,44 @@ const props = defineProps({
required: true,
},
});
const currentUser = useMapGetter('getCurrentUser');
const messages = ref([]);
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
// If the user has selected a specific assistant, it takes first preference for Copilot.
if (preferredId) {
const preferredAssistant = assistants.value.find(a => a.id === preferredId);
// Return the preferred assistant if found, otherwise continue to next cases
if (preferredAssistant) return preferredAssistant;
}
// If the above is not available, the assistant connected to the inbox takes preference.
if (inboxAssistant.value) {
const inboxMatchedAssistant = assistants.value.find(
a => a.id === inboxAssistant.value.id
);
if (inboxMatchedAssistant) return inboxMatchedAssistant;
}
// If neither of the above is available, the first assistant in the account takes preference.
return assistants.value[0];
});
const setAssistant = async assistant => {
selectedAssistantId.value = assistant.id;
await updateUISettings({
preferred_captain_assistant_id: assistant.id,
});
};
const handleReset = () => {
messages.value = [];
@@ -42,7 +79,7 @@ const sendMessage = async message => {
}))
.slice(0, -1),
message,
assistant_id: 16,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
@@ -57,6 +94,17 @@ const sendMessage = async message => {
isCaptainTyping.value = false;
}
};
onMounted(() => {
store.dispatch('captainAssistants/get');
});
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script>
<template>
@@ -65,6 +113,9 @@ const sendMessage = async message => {
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
@@ -23,17 +23,16 @@ export default {
<template>
<div
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-slate-75 dark:border-slate-600"
class="flex flex-col min-w-[15rem] max-h-[21.25rem] max-w-[23.75rem] rounded-md border border-solid border-n-strong"
:class="{
'bg-woot-25 dark:bg-slate-700 border border-solid border-woot-300 dark:border-woot-400':
'bg-woot-25 dark:bg-n-solid-2 border border-solid border-n-blue-border':
active,
}"
>
<div
class="flex justify-between items-center px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 rounded-t-[5px] border-b border-solid border-slate-50 dark:border-slate-600"
class="flex justify-between items-center rounded-t-md px-2 w-full h-10 bg-slate-50 dark:bg-slate-900 border-b border-solid border-n-strong"
:class="{
'bg-woot-50 border-b border-solid border-woot-75 dark:border-woot-700':
active,
'bg-woot-50 border-b border-solid border-n-blue-border': active,
}"
>
<div class="flex items-center p-1 text-sm font-medium">{{ heading }}</div>
@@ -38,20 +38,20 @@ export default {
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 19px;
height: 1.188rem;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 34px;
width: 2.125rem;
&.active {
background-color: var(--w-500);
}
&.small {
width: 22px;
height: 14px;
width: 1.375rem;
height: 0.875rem;
span {
height: var(--space-one);
@@ -1,6 +1,6 @@
<script setup>
import { ref, provide, onMounted, computed } from 'vue';
import { useEventListener } from '@vueuse/core';
import { ref, useTemplateRef, provide, computed, watch } from 'vue';
import { useElementSize } from '@vueuse/core';
const props = defineProps({
index: {
@@ -19,6 +19,12 @@ const props = defineProps({
const emit = defineEmits(['change']);
const tabsContainer = useTemplateRef('tabsContainer');
const tabsList = useTemplateRef('tabsList');
const { width: containerWidth } = useElementSize(tabsContainer);
const { width: listWidth } = useElementSize(tabsList);
const hasScroll = ref(false);
const activeIndex = computed({
@@ -34,20 +40,16 @@ provide('updateActiveIndex', index => {
});
const computeScrollWidth = () => {
// TODO: use useElementSize from vueuse
const tabElement = document.querySelector('.tabs');
if (tabElement) {
hasScroll.value = tabElement.scrollWidth > tabElement.clientWidth;
if (tabsContainer.value && tabsList.value) {
hasScroll.value = tabsList.value.scrollWidth > tabsList.value.clientWidth;
}
};
const onScrollClick = direction => {
// TODO: use useElementSize from vueuse
const tabElement = document.querySelector('.tabs');
if (tabElement) {
let scrollPosition = tabElement.scrollLeft;
if (tabsContainer.value && tabsList.value) {
let scrollPosition = tabsList.value.scrollLeft;
scrollPosition += direction === 'left' ? -100 : 100;
tabElement.scrollTo({
tabsList.value.scrollTo({
top: 0,
left: scrollPosition,
behavior: 'smooth',
@@ -55,14 +57,19 @@ const onScrollClick = direction => {
}
};
useEventListener(window, 'resize', computeScrollWidth);
onMounted(() => {
computeScrollWidth();
});
// Watch for changes in element sizes with immediate execution
watch(
[containerWidth, listWidth],
() => {
computeScrollWidth();
},
{ immediate: true }
);
</script>
<template>
<div
ref="tabsContainer"
:class="{
'tabs--container--with-border': border,
'tabs--container--compact': isCompact,
@@ -76,7 +83,7 @@ onMounted(() => {
>
<fluent-icon icon="chevron-left" :size="16" />
</button>
<ul :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
<ul ref="tabsList" :class="{ 'tabs--with-scroll': hasScroll }" class="tabs">
<slot />
</ul>
<button
@@ -72,26 +72,26 @@ export default {
&.active {
h3 {
@apply text-woot-500 dark:text-woot-500;
@apply text-n-blue-text dark:text-n-blue-text;
}
.step {
@apply bg-woot-500 dark:bg-woot-500;
@apply bg-n-brand dark:bg-n-brand;
}
}
&.over {
&::after {
@apply bg-woot-500 dark:bg-woot-500;
@apply bg-n-brand dark:bg-n-brand;
}
.step {
@apply bg-woot-500 dark:bg-woot-500;
@apply bg-n-brand dark:bg-n-brand;
}
& + .item {
&::before {
@apply bg-woot-500 dark:bg-woot-500;
@apply bg-n-brand dark:bg-n-brand;
}
}
}
@@ -201,10 +201,10 @@ export default {
<style lang="scss" scoped>
.filter {
@apply bg-slate-50 dark:bg-slate-800 p-2 border border-solid border-slate-75 dark:border-slate-600 rounded-md mb-2;
@apply bg-n-slate-3 dark:bg-n-solid-3 p-2 border border-solid border-n-strong dark:border-n-strong rounded-md mb-2;
&.is-a-macro {
@apply mb-0 bg-white dark:bg-slate-700 p-0 border-0 rounded-none;
@apply mb-0 bg-n-slate-2 dark:bg-n-solid-3 p-0 border-0 rounded-none;
}
}
@@ -128,7 +128,7 @@ export default {
getInputErrorClass(errorMessage) {
return errorMessage
? 'bg-red-50 dark:bg-red-800/50 border-red-100 dark:border-red-700/50'
: 'bg-slate-50 dark:bg-slate-800 border-slate-75 dark:border-slate-700/50';
: 'bg-n-slate-3 dark:bg-n-solid-3 border-n-strong dark:border-n-strong';
},
},
};
@@ -14,7 +14,7 @@ export default {
</script>
<template>
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak">
<div class="pt-4 pb-0 px-8 border-b border-solid border-n-weak/60">
<h2 class="text-2xl text-slate-800 dark:text-slate-100 mb-1 font-medium">
{{ headerTitle }}
</h2>
@@ -127,7 +127,7 @@ export default {
const uploadRef = ref(false);
const keyboardEvents = {
'Alt+KeyA': {
'$mod+Alt+KeyA': {
action: () => {
// TODO: This is really hacky, we need to replace the file picker component with
// a custom one, where the logic and the component markup is isolated.
@@ -85,7 +85,7 @@ export default {
</script>
<template>
<div class="flex justify-between h-[52px] gap-2 ltr:pl-3 rtl:pr-3">
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
<EditorModeToggle
:mode="mode"
class="mt-3"
@@ -411,7 +411,7 @@ export default {
}
.checkbox-wrapper {
@apply h-10 w-10 flex items-center justify-center rounded-full cursor-pointer mt-4;
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
@@ -53,7 +53,7 @@ const showCopilotTab = computed(() =>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[320px] w-[320px] 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-80 min-w-80 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
@@ -115,7 +115,6 @@ export default {
isProgrammaticScroll: false,
messageSentSinceOpened: false,
labelSuggestions: [],
markReadTimeout: null,
};
},
@@ -273,7 +272,6 @@ export default {
}
this.fetchAllAttachmentsFromCurrentChat();
this.fetchSuggestions();
this.removeMarkReadTimeout();
this.messageSentSinceOpened = false;
},
},
@@ -298,7 +296,6 @@ export default {
unmounted() {
this.removeBusListeners();
this.removeScrollListener();
this.removeMarkReadTimeout();
},
methods: {
@@ -365,9 +362,7 @@ export default {
this.scrollToBottom();
}
});
this.markReadTimeout = setTimeout(this.makeMessagesRead, 5000, {
once: true,
});
this.makeMessagesRead();
},
addScrollListener() {
this.conversationPanel = this.$el.querySelector('.conversation-panel');
@@ -478,9 +473,6 @@ export default {
return false;
});
},
removeMarkReadTimeout() {
clearTimeout(this.markReadTimeout);
},
},
};
</script>
@@ -2,11 +2,13 @@
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const props = defineProps({
conversationId: {
@@ -45,7 +47,7 @@ const statusDesiredOrder = [
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const inputStyles = { borderRadius: '0.75rem', fontSize: '0.875rem' };
const formState = reactive({
title: '',
@@ -188,6 +190,7 @@ const createIssue = async () => {
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
@@ -209,7 +212,7 @@ onMounted(getTeams);
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:styles="{ ...inputStyles, padding: '0.375rem 0.75rem' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
@@ -221,7 +224,7 @@ onMounted(getTeams);
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }"
:style="{ ...inputStyles, padding: '0.5rem 0.75rem' }"
rows="3"
class="text-sm"
:placeholder="
@@ -1,11 +1,13 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const props = defineProps({
conversationId: {
@@ -85,6 +87,7 @@ const linkIssue = async () => {
searchQuery.value = '';
issues.value = [];
onClose();
useTrack(LINEAR_EVENTS.LINK_ISSUE);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
@@ -6,6 +6,8 @@ import { useI18n } from 'vue-i18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import { useTrack } from 'dashboard/composables';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
@@ -56,6 +58,7 @@ const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
@@ -1,41 +1,34 @@
<script setup>
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { SHORTCUT_KEYS } from './constants';
import { SHORTCUT_KEYS, KEYS } from './constants';
import {
LAYOUT_QWERTZ,
keysToModifyInQWERTZ,
} from 'shared/helpers/KeyboardHelpers';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
defineProps({
show: {
type: Boolean,
default: false,
},
});
defineProps({ show: Boolean });
defineEmits(['close']);
const { t } = useI18n();
const shortcutKeys = SHORTCUT_KEYS;
const currentLayout = ref(null);
const title = item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
const title = computed(
() => item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`)
);
// Added this function to check if the keySet needs a shift key
// This is used to display the shift key in the modal
// If the current layout is QWERTZ and the keySet contains a key that needs a shift key
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
// https://github.com/chatwoot/chatwoot/issues/9492
const needsShiftKey = keySet => {
return (
const needsShiftKey = computed(
() => keySet =>
currentLayout.value === LAYOUT_QWERTZ &&
keySet.some(key => keysToModifyInQWERTZ.has(key))
);
};
);
onMounted(async () => {
currentLayout.value = await useDetectKeyboardLayout();
@@ -55,80 +48,46 @@ onMounted(async () => {
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.WINDOWS_KEY_AND_COMMAND_KEY') }}
{{ KEYS.WIN }}
</Hotkey>
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
{{ KEYS.SLASH }}
</Hotkey>
</div>
</div>
</div>
<div class="grid grid-cols-2 px-8 pt-0 pb-8 gap-x-5 gap-y-3">
<div class="flex justify-between items-center min-w-[25rem]">
<h5 class="text-sm text-slate-800 dark:text-slate-100">
{{ $t('KEYBOARD_SHORTCUTS.TITLE.OPEN_CONVERSATION') }}
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<div class="flex gap-2">
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</Hotkey>
<Hotkey custom-class="min-h-[28px] w-9 key"> {{ 'J' }} </Hotkey>
<span
class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
>
{{ $t('KEYBOARD_SHORTCUTS.KEYS.FORWARD_SLASH_KEY') }}
</span>
</div>
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</Hotkey>
<Hotkey custom-class="w-9 key"> {{ 'K' }} </Hotkey>
</div>
</div>
<div class="flex justify-between items-center min-w-[25rem]">
<h5 class="text-sm text-slate-800 dark:text-slate-100">
{{ $t('KEYBOARD_SHORTCUTS.TITLE.RESOLVE_AND_NEXT') }}
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.WINDOWS_KEY_AND_COMMAND_KEY') }}
</Hotkey>
<Hotkey custom-class="min-h-[28px] min-w-[60px] normal-case key">
{{ $t('KEYBOARD_SHORTCUTS.KEYS.ALT_OR_OPTION_KEY') }}
</Hotkey>
<Hotkey custom-class="w-9 key"> {{ 'E' }} </Hotkey>
</div>
</div>
<div
v-for="shortcutKey in shortcutKeys"
:key="shortcutKey.id"
v-for="shortcut in SHORTCUT_KEYS"
:key="shortcut.id"
class="flex justify-between items-center min-w-[25rem]"
>
<h5 class="text-sm text-slate-800 min-w-[36px] dark:text-slate-100">
{{ title(shortcutKey) }}
{{ title(shortcut) }}
</h5>
<div class="flex items-center gap-2 mb-1 ml-2">
<Hotkey
v-if="needsShiftKey(shortcutKey.keySet)"
custom-class="min-h-[28px] min-w-[36px] key"
>
{{ 'Shift' }}
</Hotkey>
<Hotkey
:class="{ 'min-w-[60px]': shortcutKey.firstKey !== 'Up' }"
custom-class="min-h-[28px] normal-case key"
>
{{ shortcutKey.firstKey }}
</Hotkey>
<Hotkey
:class="{ 'normal-case': shortcutKey.secondKey === 'Down' }"
custom-class="min-h-[28px] min-w-[36px] key"
>
{{ shortcutKey.secondKey }}
</Hotkey>
<template v-if="needsShiftKey(shortcut.keySet)">
<Hotkey custom-class="min-h-[28px] min-w-[36px] key">
{{ KEYS.SHIFT }}
</Hotkey>
</template>
<template v-for="(key, index) in shortcut.displayKeys" :key="index">
<template v-if="key !== KEYS.SLASH">
<Hotkey
custom-class="min-h-[28px] min-w-[36px] key normal-case"
>
{{ key }}
</Hotkey>
</template>
<span
v-else
class="flex items-center text-sm font-semibold text-slate-800 dark:text-slate-100"
>
{{ key }}
</span>
</template>
</div>
</div>
</div>
@@ -1,86 +1,95 @@
export const KEYS = {
ALT: 'Alt / ⌥',
WIN: 'Win / ⌘',
SHIFT: 'Shift',
SLASH: '/',
UP: 'Up',
DOWN: 'Down',
};
export const SHORTCUT_KEYS = [
{
id: 1,
label: 'NAVIGATE_DROPDOWN',
firstKey: 'Up',
secondKey: 'Down',
keySet: ['ArrowUp', 'ArrowDown'],
label: 'OPEN_CONVERSATION',
displayKeys: [KEYS.ALT, 'J', KEYS.SLASH, KEYS.ALT, 'K'],
keySet: ['Alt+KeyJ', 'Alt+KeyK'],
},
{
id: 2,
label: 'RESOLVE_CONVERSATION',
firstKey: 'Alt / ⌥',
secondKey: 'E',
keySet: ['Alt+KeyE'],
label: 'RESOLVE_AND_NEXT',
displayKeys: [KEYS.WIN, KEYS.ALT, 'E'],
keySet: ['$mod+Alt+KeyE'],
},
{
id: 3,
label: 'GO_TO_CONVERSATION_DASHBOARD',
firstKey: 'Alt / ⌥',
secondKey: 'C',
keySet: ['Alt+KeyC'],
label: 'NAVIGATE_DROPDOWN',
displayKeys: [KEYS.UP, KEYS.DOWN],
keySet: ['ArrowUp', 'ArrowDown'],
},
{
id: 4,
label: 'ADD_ATTACHMENT',
firstKey: 'Alt / ⌥',
secondKey: 'A',
keySet: ['Alt+KeyA'],
label: 'RESOLVE_CONVERSATION',
displayKeys: [KEYS.ALT, 'E'],
keySet: ['Alt+KeyE'],
},
{
id: 5,
label: 'GO_TO_CONTACTS_DASHBOARD',
firstKey: 'Alt / ⌥',
secondKey: 'V',
keySet: ['Alt+KeyV'],
label: 'GO_TO_CONVERSATION_DASHBOARD',
displayKeys: [KEYS.ALT, 'C'],
keySet: ['Alt+KeyC'],
},
{
id: 6,
label: 'TOGGLE_SIDEBAR',
firstKey: 'Alt / ⌥',
secondKey: 'O',
keySet: ['Alt+KeyO'],
label: 'ADD_ATTACHMENT',
displayKeys: [KEYS.WIN, KEYS.ALT, 'A'],
keySet: ['$mod+Alt+KeyA'],
},
{
id: 7,
label: 'GO_TO_REPORTS_SIDEBAR',
firstKey: 'Alt / ⌥',
secondKey: 'R',
keySet: ['Alt+KeyR'],
label: 'GO_TO_CONTACTS_DASHBOARD',
displayKeys: [KEYS.ALT, 'V'],
keySet: ['Alt+KeyV'],
},
{
id: 8,
label: 'MOVE_TO_NEXT_TAB',
firstKey: 'Alt / ⌥',
secondKey: 'N',
keySet: ['Alt+KeyN'],
label: 'TOGGLE_SIDEBAR',
displayKeys: [KEYS.ALT, 'O'],
keySet: ['Alt+KeyO'],
},
{
id: 9,
label: 'GO_TO_SETTINGS',
firstKey: 'Alt / ⌥',
secondKey: 'S',
keySet: ['Alt+KeyS'],
label: 'GO_TO_REPORTS_SIDEBAR',
displayKeys: [KEYS.ALT, 'R'],
keySet: ['Alt+KeyR'],
},
{
id: 10,
label: 'MOVE_TO_NEXT_TAB',
displayKeys: [KEYS.ALT, 'N'],
keySet: ['Alt+KeyN'],
},
{
id: 11,
label: 'SWITCH_TO_PRIVATE_NOTE',
firstKey: 'Alt / ⌥',
secondKey: 'P',
keySet: ['Alt+KeyP'],
label: 'GO_TO_SETTINGS',
displayKeys: [KEYS.ALT, 'S'],
keySet: ['Alt+KeyS'],
},
{
id: 12,
label: 'SWITCH_TO_REPLY',
firstKey: 'Alt / ⌥',
secondKey: 'L',
keySet: ['Alt+KeyL'],
label: 'SWITCH_TO_PRIVATE_NOTE',
displayKeys: [KEYS.ALT, 'P'],
keySet: ['Alt+KeyP'],
},
{
id: 13,
label: 'SWITCH_TO_REPLY',
displayKeys: [KEYS.ALT, 'L'],
keySet: ['Alt+KeyL'],
},
{
id: 14,
label: 'TOGGLE_SNOOZE_DROPDOWN',
firstKey: 'Alt / ⌥',
secondKey: 'M',
displayKeys: [KEYS.ALT, 'M'],
keySet: ['Alt+KeyM'],
},
];
@@ -0,0 +1,175 @@
import { ref } from 'vue';
import { useFontSize } from '../useFontSize';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
// Mock dependencies
vi.mock('dashboard/composables/useUISettings');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn(cb => cb());
describe('useFontSize', () => {
const mockUISettings = ref({
font_size: '16px',
});
const mockUpdateUISettings = vi.fn().mockResolvedValue(undefined);
const mockTranslate = vi.fn(key => key);
beforeEach(() => {
vi.clearAllMocks();
// Setup mocks
useUISettings.mockReturnValue({
uiSettings: mockUISettings,
updateUISettings: mockUpdateUISettings,
});
useI18n.mockReturnValue({
t: mockTranslate,
});
// Reset DOM state
document.documentElement.style.removeProperty('font-size');
// Reset mockUISettings to default
mockUISettings.value = { font_size: '16px' };
});
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions).toHaveLength(6);
expect(fontSizeOptions[0]).toHaveProperty('value');
expect(fontSizeOptions[0]).toHaveProperty('label');
// Check specific options
expect(fontSizeOptions.find(option => option.value === '16px')).toEqual({
value: '16px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
});
expect(fontSizeOptions.find(option => option.value === '14px')).toEqual({
value: '14px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
});
expect(fontSizeOptions.find(option => option.value === '22px')).toEqual({
value: '22px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE',
});
});
it('returns currentFontSize from UI settings', () => {
const { currentFontSize } = useFontSize();
expect(currentFontSize.value).toBe('16px');
mockUISettings.value.font_size = '18px';
expect(currentFontSize.value).toBe('18px');
});
it('applies font size to document root correctly based on pixel values', () => {
const { applyFontSize } = useFontSize();
applyFontSize('18px');
expect(document.documentElement.style.fontSize).toBe('18px');
applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('22px');
expect(document.documentElement.style.fontSize).toBe('22px');
applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px');
});
it('updates UI settings and applies font size', async () => {
const { updateFontSize } = useFontSize();
await updateFontSize('20px');
expect(mockUpdateUISettings).toHaveBeenCalledWith({ font_size: '20px' });
expect(document.documentElement.style.fontSize).toBe('20px');
expect(useAlert).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS'
);
});
it('shows error alert when update fails', async () => {
mockUpdateUISettings.mockRejectedValueOnce(new Error('Update failed'));
const { updateFontSize } = useFontSize();
await updateFontSize('20px');
expect(useAlert).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR'
);
});
it('handles unknown font size values gracefully', () => {
const { applyFontSize } = useFontSize();
// Should not throw an error and should apply the default font size
applyFontSize('unknown-size');
expect(document.documentElement.style.fontSize).toBe('16px');
});
it('watches for UI settings changes and applies font size', async () => {
useFontSize();
// Initial font size should now be 16px instead of empty
expect(document.documentElement.style.fontSize).toBe('16px');
// Update UI settings
mockUISettings.value = { font_size: '18px' };
// Wait for next tick to let watchers fire
await Promise.resolve();
expect(document.documentElement.style.fontSize).toBe('18px');
});
it('translates font size option labels correctly', () => {
// Set up specific translation mapping
mockTranslate.mockImplementation(key => {
const translations = {
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER':
'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.EXTRA_LARGE':
'Extra Large',
};
return translations[key] || key;
});
const { fontSizeOptions } = useFontSize();
// Check that translation is applied
expect(fontSizeOptions.find(option => option.value === '14px').label).toBe(
'Smaller'
);
expect(fontSizeOptions.find(option => option.value === '16px').label).toBe(
'Default'
);
expect(fontSizeOptions.find(option => option.value === '22px').label).toBe(
'Extra Large'
);
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER'
);
expect(mockTranslate).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT'
);
});
});
@@ -0,0 +1,140 @@
/**
* @file useFontSize.js
* @description A composable for managing font size settings throughout the application.
* This handles font size selection, application to the DOM, and persistence in user settings.
*/
import { computed, watch } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
/**
* Font size options with their pixel values
* @type {Object}
*/
const FONT_SIZE_OPTIONS = {
SMALLER: '14px',
SMALL: '15px',
DEFAULT: '16px',
LARGE: '18px',
LARGER: '20px',
EXTRA_LARGE: '22px',
};
/**
* Array of font size option keys
* @type {Array<string>}
*/
const FONT_SIZE_NAMES = Object.keys(FONT_SIZE_OPTIONS);
/**
* Get font size label translation key
*
* @param {string} name - Font size name
* @returns {string} Translation key
*/
const getFontSizeLabelKey = name =>
`PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.${name}`;
/**
* Create font size option object
*
* @param {Function} t - Translation function
* @param {string} name - Font size name
* @returns {Object} Font size option with value and label
*/
const createFontSizeOption = (t, name) => ({
value: FONT_SIZE_OPTIONS[name],
label: t(getFontSizeLabelKey(name)),
});
/**
* Apply font size value to document root
*
* @param {string} pixelValue - Font size value in pixels
*/
const applyFontSizeToDOM = pixelValue => {
document.documentElement.style.setProperty(
'font-size',
pixelValue ?? FONT_SIZE_OPTIONS.DEFAULT
);
};
/**
* Font size management composable
*
* @returns {Object} Font size utilities and state
* @property {Array} fontSizeOptions - Array of font size options for select components
* @property {import('vue').ComputedRef<string>} currentFontSize - Current font size from UI settings
* @property {Function} applyFontSize - Function to apply font size to document
* @property {Function} updateFontSize - Function to update font size in settings with alert feedback
*/
export const useFontSize = () => {
const { uiSettings, updateUISettings } = useUISettings();
const { t } = useI18n();
/**
* Font size options for select dropdown
* @type {Array<{value: string, label: string}>}
*/
const fontSizeOptions = FONT_SIZE_NAMES.map(name =>
createFontSizeOption(t, name)
);
/**
* Current font size from UI settings
* @type {import('vue').ComputedRef<string>}
*/
const currentFontSize = computed(
() => uiSettings.value.font_size || FONT_SIZE_OPTIONS.DEFAULT
);
/**
* Apply font size to document root
* @param {string} pixelValue - Font size in pixels (e.g., '16px')
* @returns {void}
*/
const applyFontSize = pixelValue => {
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => applyFontSizeToDOM(pixelValue));
};
/**
* Update font size in settings and apply to document
* Shows success/error alerts
* @param {string} pixelValue - Font size in pixels (e.g., '16px')
* @returns {Promise<void>}
*/
const updateFontSize = async pixelValue => {
try {
await updateUISettings({ font_size: pixelValue });
applyFontSize(pixelValue);
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS')
);
} catch (error) {
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR')
);
}
};
// Watch for changes to the font size in UI settings
watch(
() => uiSettings.value.font_size,
newSize => {
applyFontSize(newSize);
},
{ immediate: true }
);
return {
fontSizeOptions,
currentFontSize,
applyFontSize,
updateFontSize,
};
};
export default useFontSize;
@@ -124,3 +124,9 @@ export const SLA_EVENTS = Object.freeze({
UPDATE: 'Updated an SLA',
DELETED: 'Deleted an SLA',
});
export const LINEAR_EVENTS = Object.freeze({
CREATE_ISSUE: 'Created a linear issue',
LINK_ISSUE: 'Linked a linear issue',
UNLINK_ISSUE: 'Unlinked a linear issue',
});
@@ -9,9 +9,9 @@ export const setColorTheme = isOSOnDarkMode => {
selectedColorScheme === 'dark'
) {
document.body.classList.add('dark');
document.documentElement.setAttribute('style', 'color-scheme: dark;');
document.documentElement.style.setProperty('color-scheme', 'dark');
} else {
document.body.classList.remove('dark');
document.documentElement.setAttribute('style', 'color-scheme: light;');
document.documentElement.style.setProperty('color-scheme', 'light');
}
};
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " Merge contacts",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Contact saved successfully",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.",
@@ -117,6 +117,7 @@
"PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Reopen conversation",
"SNOOZE": {
"TITLE": "Snooze",
@@ -300,6 +300,12 @@
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
@@ -310,7 +316,8 @@
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset"
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
@@ -433,6 +440,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
@@ -35,6 +35,24 @@
}
}
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "سيتم نسخ تفاصيل الاتصال من <strong>{primaryContactName}</strong> إلى <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "رسالة_خطأ"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " دمج جهات الاتصال",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "رسالة",
"SEND_MESSAGE": "إرسال الرسالة",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "جهات الاتصال"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "تم حفظ جهة الاتصال بنجاح",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "تم إلغاء حجب جهة الاتصال بنجاح",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "استيراد جهات الاتصال من خلال ملف CSV.",
@@ -117,6 +117,7 @@
"PENDING": "تحديد كمعلق",
"RESOLVED": "تحديد كمحلولة",
"MARK_AS_UNREAD": "وضع علامة كغير مقروء",
"MARK_AS_READ": "تحديد كمقروء",
"REOPEN": "إعادة فتح المحادثة",
"SNOOZE": {
"TITLE": "تأجيل",
@@ -300,6 +300,12 @@
"TITLE": "إلغاء الربط",
"SUCCESS": "تم إلغاء ربط المشكلة بنجاح",
"ERROR": "حدث خطأ أثناء إلغاء ربط المشكلة، الرجاء المحاولة مرة أخرى"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "نعم، احذف",
"CANCEL": "إلغاء"
}
}
},
@@ -310,7 +316,8 @@
"LOADER": "Captain is thinking",
"YOU": "أنت",
"USE": "Use this",
"RESET": "Reset"
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
@@ -433,6 +440,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "حذف",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
@@ -35,6 +35,24 @@
}
}
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "افتراضي",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "توقيع الرسالة الشخصية",
"NOTE": "إنشاء توقيع رسالة فريدة تظهر في نهاية كل رسالة ترسلها من أي صندوق وارد. يمكنك أيضًا تضمين صورة داخلية، مدعومة في الدردشة المباشرة، والبريد الإلكتروني، وصناديق API الواردة.",
@@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "التبديل إلى الملاحظة الخاصة",
"SWITCH_TO_REPLY": "التبديل إلى الرد",
"TOGGLE_SNOOZE_DROPDOWN": "تبديل القائمة المنسدلة"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘\n",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " Merge contacts",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Contact saved successfully",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.",
@@ -117,6 +117,7 @@
"PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Reopen conversation",
"SNOOZE": {
"TITLE": "Snooze",
@@ -300,6 +300,12 @@
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
}
}
},
@@ -310,7 +316,8 @@
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset"
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
@@ -433,6 +440,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Delete",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
@@ -35,6 +35,24 @@
}
}
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Детайлите на контакт <strong>{primaryContactName}</strong> ще бъдат копирани в <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ГРЕШКА"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " Обединяване на контакти",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Съобщение",
"SEND_MESSAGE": "Изпрати съобщение",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "Контакти"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Успешно запазване на контакта",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Внасяне на контакти чрез CSV файл.",
@@ -117,6 +117,7 @@
"PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved",
"MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Reopen conversation",
"SNOOZE": {
"TITLE": "Snooze",
@@ -300,6 +300,12 @@
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Отмени"
}
}
},
@@ -310,7 +316,8 @@
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset"
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
@@ -433,6 +440,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Изтрий",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
@@ -35,6 +35,24 @@
}
}
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Les dades de contacte de <strong>{primaryContactName}</strong> es copiaran a <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " Fusiona contactes",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Missatge",
"SEND_MESSAGE": "Envia missatge",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "Contactes"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Contacte guardat correctament",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "Aquest contacte s'ha desbloquejat correctament",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Importa contactes a través d'un fitxer CSV.",
@@ -117,6 +117,7 @@
"PENDING": "Marca com a pendent",
"RESOLVED": "Marca com a resolt",
"MARK_AS_UNREAD": "Marca com a no llegit",
"MARK_AS_READ": "Marca com a llegit",
"REOPEN": "Torna a obrir la conversa",
"SNOOZE": {
"TITLE": "Posposat",
@@ -300,6 +300,12 @@
"TITLE": "Desenllaça",
"SUCCESS": "S'ha desenllaçat la issue correctament",
"ERROR": "S'ha produït un error en desenllaçar la issue, torna-ho a provar"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Sí, esborra",
"CANCEL": "Cancel·la"
}
}
},
@@ -310,7 +316,8 @@
"LOADER": "Captain is thinking",
"YOU": "Tu",
"USE": "Use this",
"RESET": "Reset"
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
@@ -433,6 +440,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "Esborrar",
"BULK_APPROVE": {
"SUCCESS_MESSAGE": "FAQs approved successfully",
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
},
"BULK_DELETE": {
"TITLE": "Delete FAQs?",
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "FAQs deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the FAQs, please try again."
},
"DELETE": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
@@ -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": {
"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.",
@@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Canvia a la nota privada",
"SWITCH_TO_REPLY": "Canvia a Respon",
"TOGGLE_SNOOZE_DROPDOWN": "Commuta el menú desplegable de posposar"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Contact details of <strong>{primaryContactName}</strong> will be copied to <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "ERROR_MESSAGE"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " Merge contacts",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Zpráva",
"SEND_MESSAGE": "Send message",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "Kontakty"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Kontakt byl úspěšně uložen",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Import contacts through a CSV file.",
@@ -117,6 +117,7 @@
"PENDING": "Označit jako nevyřízené",
"RESOLVED": "Označit jako vyřešené",
"MARK_AS_UNREAD": "Mark as unread",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Znovu otevřít konverzaci",
"SNOOZE": {
"TITLE": "Odložit",
@@ -300,6 +300,12 @@
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
},
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Zrušit"
}
}
},
@@ -310,7 +316,8 @@
"LOADER": "Captain is thinking",
"YOU": "Vy",
"USE": "Use this",
"RESET": "Reset"
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
@@ -433,6 +440,20 @@
"DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}"
},
"SELECTED": "{count} selected",
"BULK_APPROVE_BUTTON": "Approve",
"BULK_DELETE_BUTTON": "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": {
"TITLE": "Are you sure to delete the FAQ?",
"DESCRIPTION": "",
@@ -35,6 +35,24 @@
}
}
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",
"FONT_SIZE": {
"TITLE": "Font size",
"NOTE": "Adjust the text size across the dashboard based on your preference.",
"UPDATE_SUCCESS": "Your font settings have been updated successfully",
"UPDATE_ERROR": "There is an error while updating the font settings, please try again",
"OPTIONS": {
"SMALLER": "Smaller",
"SMALL": "Small",
"DEFAULT": "Default",
"LARGE": "Large",
"LARGER": "Larger",
"EXTRA_LARGE": "Extra Large"
}
}
},
"MESSAGE_SIGNATURE_SECTION": {
"TITLE": "Personal message signature",
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
@@ -389,11 +407,6 @@
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
},
"KEYS": {
"WINDOWS_KEY_AND_COMMAND_KEY": "Win / ⌘",
"ALT_OR_OPTION_KEY": "Alt / ⌥",
"FORWARD_SLASH_KEY": "/"
}
}
}
@@ -35,7 +35,7 @@
},
"API": {
"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": {
@@ -9,7 +9,7 @@
"FAILED_TO_SEND": "Failed to send",
"TAB_HEADING": "Samtaler",
"MENTION_HEADING": "Omtaler",
"UNATTENDED_HEADING": "Unattet",
"UNATTENDED_HEADING": "Ubehandlet",
"SEARCH": {
"INPUT": "Søg efter Mennesker, Chats, Gemte svar .."
},
@@ -266,7 +266,7 @@
"ATTRIBUTE_WARNING": "Kontaktoplysninger på <strong>{primaryContactName}</strong> vil blive kopieret til <strong>{parentContactName}</strong>."
},
"SEARCH": {
"ERROR": "FEJL_MEDDELELSE"
"ERROR_MESSAGE": "Something went wrong. Please try again later."
},
"FORM": {
"SUBMIT": " Sammenflet kontakter",
@@ -288,6 +288,8 @@
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Besked",
"SEND_MESSAGE": "Send besked",
"BLOCK_CONTACT": "Block contact",
"UNBLOCK_CONTACT": "Unblock contact",
"BREADCRUMB": {
"CONTACTS": "Kontakter"
},
@@ -302,6 +304,10 @@
"SUCCESS_MESSAGE": "Kontakt gemt med succes",
"ERROR_MESSAGE": "Unable to save contact. Please try again later."
},
"BLOCK_SUCCESS_MESSAGE": "This contact is blocked successfully",
"BLOCK_ERROR_MESSAGE": "Unable to block contact. Please try again later.",
"UNBLOCK_SUCCESS_MESSAGE": "This contact is unblocked successfully",
"UNBLOCK_ERROR_MESSAGE": "Unable to unblock contact. Please try again later.",
"IMPORT_CONTACT": {
"TITLE": "Import contacts",
"DESCRIPTION": "Importér kontakter via en CSV-fil.",
@@ -117,6 +117,7 @@
"PENDING": "Markér som afventende",
"RESOLVED": "Marker som løst",
"MARK_AS_UNREAD": "Marker som ulæst",
"MARK_AS_READ": "Mark as read",
"REOPEN": "Genåbn samtale",
"SNOOZE": {
"TITLE": "Udsæt",

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