Compare commits

..
Author SHA1 Message Date
Shivam Mishra 2eff74c854 feat: make response usage increment atomic 2026-01-08 21:26:14 +05:30
504 changed files with 1753 additions and 12212 deletions
+3 -47
View File
@@ -76,7 +76,7 @@ jobs:
bundle install bundle install
- node/install: - node/install:
node-version: '24.12' node-version: '23.7'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '24.12' node-version: '23.7'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '24.12' node-version: '23.7'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
@@ -218,49 +218,6 @@ jobs:
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
bundle install bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database # Configure environment and database
- run: - run:
name: Database Setup and Configure Environment Variables name: Database Setup and Configure Environment Variables
@@ -277,7 +234,6 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env" echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup # Database setup
- run: - run:
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base dockerfile: .devcontainer/Dockerfile.base
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.12.0' NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
run: pnpm i run: pnpm i
@@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
run: pnpm i run: pnpm i
@@ -94,7 +94,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 24 node-version: 23
cache: 'pnpm' cache: 'pnpm'
- name: pnpm - name: pnpm
+1 -1
View File
@@ -1 +1 @@
24.12.0 23.7.0
+2 -4
View File
@@ -441,8 +441,7 @@ GEM
http-cookie (1.0.5) http-cookie (1.0.5)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0) http-form_data (2.3.0)
httparty (0.24.0) httparty (0.21.0)
csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
@@ -556,8 +555,7 @@ GEM
ruby2_keywords ruby2_keywords
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.8.0) multi_xml (0.6.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.3.0) multipart-post (2.3.0)
mutex_m (0.3.0) mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)
+2 -1
View File
@@ -8,6 +8,7 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
4.9.2 4.9.1
+1 -1
View File
@@ -1 +1 @@
3.5.0 3.4.3
@@ -1,38 +0,0 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
@@ -1,76 +0,0 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
@@ -28,7 +28,5 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type, search_type: search_type,
params: params params: params
).perform ).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end end
end end
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel] before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,12 +18,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder) render_report_with(V2::Reports::LabelSummaryBuilder)
end end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private private
def check_authorization def check_authorization
@@ -46,12 +40,4 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params def permitted_params
params.permit(:since, :until, :business_hours) params.permit(:since, :until, :business_hours)
end end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end end
+1 -1
View File
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL API_CHANNEL_THUMBNAIL
CLOUD_ANALYTICS_TOKEN ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY HCAPTCHA_SITE_KEY
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainPreferences extends ApiClient {
constructor() {
super('captain/preferences', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
updatePreferences(data) {
return axios.put(this.url, data);
}
}
export default new CaptainPreferences();
+4 -14
View File
@@ -14,48 +14,38 @@ class SearchAPI extends ApiClient {
}); });
} }
contacts({ q, page = 1, since, until }) { contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, { return axios.get(`${this.url}/contacts`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
}, },
}); });
} }
conversations({ q, page = 1, since, until }) { conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, { return axios.get(`${this.url}/conversations`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
}, },
}); });
} }
messages({ q, page = 1, since, until, from, inboxId }) { messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, { return axios.get(`${this.url}/messages`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
from,
inbox_id: inboxId,
}, },
}); });
} }
articles({ q, page = 1, since, until }) { articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, { return axios.get(`${this.url}/articles`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
}, },
}); });
} }
@@ -1,134 +0,0 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
@@ -19,7 +19,7 @@ const handleClick = () => {
<template> <template>
<div <div
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2" class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
> >
<div <div
class="flex w-full gap-3 py-5" class="flex w-full gap-3 py-5"
@@ -35,10 +35,6 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'), label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at', value: 'created_at',
}, },
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
},
]; ];
const orderingMenus = [ const orderingMenus = [
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
menuItems: { menuItems: {
@@ -38,13 +37,9 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
disableLocalFiltering: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['action', 'search']); const emit = defineEmits(['action']);
const { t } = useI18n(); const { t } = useI18n();
@@ -62,7 +57,6 @@ const flattenedMenuItems = computed(() => {
}); });
const filteredMenuItems = computed(() => { const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value; if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item => return flattenedMenuItems.value.filter(item =>
@@ -75,7 +69,7 @@ const filteredMenuSections = computed(() => {
return []; return [];
} }
if (props.disableLocalFiltering || !searchQuery.value) { if (!searchQuery.value) {
return props.menuSections; return props.menuSections;
} }
@@ -95,12 +89,6 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0); .filter(section => section.items.length > 0);
}); });
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => { const handleAction = item => {
const { action, value, ...rest } = item; const { action, value, ...rest } = item;
emit('action', { action, value, ...rest }); emit('action', { action, value, ...rest });
@@ -130,7 +118,7 @@ onMounted(() => {
> >
<div <div
v-if="showSearch" v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20" class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
> >
<div class="relative"> <div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" /> <span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -142,7 +130,6 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER') searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
" "
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12" class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/> />
</div> </div>
</div> </div>
@@ -154,23 +141,10 @@ onMounted(() => {
> >
<p <p
v-if="section.title" v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm" class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
:class="showSearch ? 'top-10' : 'top-0'"
> >
{{ section.title }} {{ section.title }}
</p> </p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button <button
v-for="(item, itemIndex) in section.items" v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex" :key="item.value || itemIndex"
@@ -261,6 +235,5 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE') : t('DROPDOWN_MENU.EMPTY_STATE')
}} }}
</div> </div>
<slot name="footer" />
</div> </div>
</template> </template>
@@ -42,10 +42,7 @@ const props = defineProps({
const emit = defineEmits(['retry']); const emit = defineEmits(['retry']);
const allMessages = computed(() => { const allMessages = computed(() => {
return useCamelCase(props.messages, { return useCamelCase(props.messages, { deep: true });
deep: true,
stopPaths: ['content_attributes.translations'],
});
}); });
const currentChat = useMapGetter('getSelectedChat'); const currentChat = useMapGetter('getSelectedChat');
@@ -17,10 +17,6 @@ const { attachment } = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
showTranscribedText: {
type: Boolean,
default: true,
},
}); });
defineOptions({ defineOptions({
@@ -186,7 +182,7 @@ const downloadAudio = async () => {
</div> </div>
<div <div
v-if="attachment.transcribedText && showTranscribedText" v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
> >
{{ attachment.transcribedText }} {{ attachment.transcribedText }}
@@ -1,11 +1,12 @@
<script setup> <script setup>
import { h, ref, computed, onMounted } from 'vue'; import { h, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider'; import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd'; import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@@ -54,7 +55,14 @@ const toggleShortcutModalFn = show => {
useSidebarKeyboardShortcuts(toggleShortcutModalFn); useSidebarKeyboardShortcuts(toggleShortcutModalFn);
const expandedItem = ref(null); // We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
sessionStorage
);
const setExpandedItem = name => { const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name; expandedItem.value = expandedItem.value === name ? null : name;
@@ -485,12 +493,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase', icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'), to: accountScopedRoute('general_settings_index'),
}, },
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{ {
name: 'Settings Agents', name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'), label: t('SIDEBAR.AGENTS'),
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, watch, nextTick } from 'vue'; import { computed, onMounted, nextTick } from 'vue';
import { useSidebarContext } from './provider'; import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
@@ -126,16 +126,6 @@ onMounted(async () => {
setExpandedItem(props.name); setExpandedItem(props.name);
} }
}); });
watch(
hasActiveChild,
hasNewActiveChild => {
if (hasNewActiveChild && !isExpanded.value) {
setExpandedItem(props.name);
}
},
{ once: true }
);
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref, onMounted, nextTick, watch } from 'vue'; import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core'; import { useResizeObserver } from '@vueuse/core';
const props = defineProps({ const props = defineProps({
@@ -31,24 +31,20 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]); const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => { const updateIndicator = () => {
nextTick(() => { if (!activeElement.value) return;
if (!activeElement.value) return;
indicatorStyle.value = { indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`, left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`, width: `${activeElement.value.offsetWidth}px`,
}; };
});
}; };
useResizeObserver(activeElement, updateIndicator); useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
}); });
onMounted(() => { onMounted(() => {
updateIndicator();
nextTick(() => { nextTick(() => {
enableTransition.value = true; enableTransition.value = true;
}); });
@@ -70,7 +66,7 @@ const showDivider = index => {
<template> <template>
<div <div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]" class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
> >
<div <div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0" class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
@@ -55,7 +55,6 @@ import {
getSelectionCoords, getSelectionCoords,
calculateMenuPosition, calculateMenuPosition,
getEffectiveChannelType, getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
@@ -132,10 +131,8 @@ const editorMenuOptions = computed(() => {
const createState = (content, placeholder, plugins = [], methods = {}) => { const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value; const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({ return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent), doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({ plugins: buildEditor({
schema, schema,
placeholder, placeholder,
@@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue'; import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue'; import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -44,6 +46,7 @@ import {
appendSignature, appendSignature,
removeSignature, removeSignature,
getEffectiveChannelType, getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
@@ -69,6 +72,8 @@ export default {
WhatsappTemplates, WhatsappTemplates,
WootMessageEditor, WootMessageEditor,
QuotedEmailPreview, QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
}, },
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: { props: {
@@ -109,6 +114,8 @@ export default {
recordingAudioState: '', recordingAudioState: '',
recordingAudioDurationText: '', recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY, replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '', bccEmails: '',
ccEmails: '', ccEmails: '',
toEmails: '', toEmails: '',
@@ -141,6 +148,9 @@ export default {
if (!senderId) return {}; if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId); return this.$store.getters['contacts/getContact'](senderId);
}, },
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() { shouldShowReplyToMessage() {
return ( return (
this.inReplyTo?.id && this.inReplyTo?.id &&
@@ -399,6 +409,19 @@ export default {
!!this.quotedEmailText !!this.quotedEmailText
); );
}, },
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
}, },
watch: { watch: {
currentChat(conversation, oldConversation) { currentChat(conversation, oldConversation) {
@@ -441,7 +464,25 @@ export default {
this.resetRecorderAndClearAttachments(); this.resetRecorderAndClearAttachments();
} }
}, },
message() { message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft. // Autosave the current message draft.
this.doAutoSaveDraft(); this.doAutoSaveDraft();
}, },
@@ -491,14 +532,20 @@ export default {
methods: { methods: {
handleInsert(article) { handleInsert(article) {
const { url, title } = article; const { url, title } = article;
// Removing empty lines from the title if (this.isRichEditorEnabled) {
const lines = title.split('\n'); // Removing empty lines from the title
const nonEmptyLines = lines.filter(line => line.trim() !== ''); const lines = title.split('\n');
const filteredMarkdown = nonEmptyLines.join(' '); const nonEmptyLines = lines.filter(line => line.trim() !== '');
emitter.emit( const filteredMarkdown = nonEmptyLines.join(' ');
BUS_EVENTS.INSERT_INTO_RICH_EDITOR, emitter.emit(
`[${filteredMarkdown}](${url})` BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
); `[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK); useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
}, },
@@ -567,14 +614,26 @@ export default {
if (this.isPrivate) { if (this.isPrivate) {
return message; return message;
} }
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType( const effectiveChannelType = getEffectiveChannelType(
this.channelType, this.channelType,
this.inbox?.medium || '' this.inbox?.medium || ''
); );
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType) ? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.messageSignature, effectiveChannelType); : removeSignature(message, this.signatureToApply);
}, },
removeFromDraft() { removeFromDraft() {
if (this.conversationIdByRoute) { if (this.conversationIdByRoute) {
@@ -590,6 +649,7 @@ export default {
Escape: { Escape: {
action: () => { action: () => {
this.hideEmojiPicker(); this.hideEmojiPicker();
this.hideMentions();
}, },
allowOnFocusedInput: true, allowOnFocusedInput: true,
}, },
@@ -634,6 +694,11 @@ export default {
// Don't handle paste if compose new conversation modal is open // Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return; if (this.newConversationModalActive) return;
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
// Filter valid files (non-zero size) // Filter valid files (non-zero size)
Array.from(e.clipboardData.files) Array.from(e.clipboardData.files)
.filter(file => file.size > 0) .filter(file => file.size > 0)
@@ -767,15 +832,19 @@ export default {
// if signature is enabled, append it to the message // if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated // appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present // so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType( if (this.showRichContentEditor) {
this.channelType, const effectiveChannelType = getEffectiveChannelType(
this.inbox?.medium || '' this.channelType,
); this.inbox?.medium || ''
message = appendSignature( );
message, message = appendSignature(
this.messageSignature, message,
effectiveChannelType this.messageSignature,
); effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
} }
const updatedMessage = replaceVariablesInMessage({ const updatedMessage = replaceVariablesInMessage({
@@ -799,30 +868,52 @@ export default {
}); });
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode; this.replyType = mode;
if (this.isRecordingAudio) { if (this.showRichContentEditor) {
this.toggleAudioRecorder(); if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
} }
this.$nextTick(() => this.$refs.messageInput.focus());
}, },
clearEditorSelection() { clearEditorSelection() {
this.updateEditorSelectionWith = ''; this.updateEditorSelectionWith = '';
}, },
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) { addIntoEditor(content) {
this.updateEditorSelectionWith = content; if (this.showRichContentEditor) {
this.onFocus(); this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
}, },
clearMessage() { clearMessage() {
this.message = ''; this.message = '';
if (this.sendWithSignature && !this.isPrivate) { if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message // if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType( if (this.showRichContentEditor) {
this.channelType, const effectiveChannelType = getEffectiveChannelType(
this.inbox?.medium || '' this.channelType,
); this.inbox?.medium || ''
this.message = appendSignature( );
this.message, this.message = appendSignature(
this.messageSignature, this.message,
effectiveChannelType this.messageSignature,
); effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
} }
this.attachedFiles = []; this.attachedFiles = [];
this.isRecordingAudio = false; this.isRecordingAudio = false;
@@ -857,6 +948,9 @@ export default {
this.toggleEmojiPicker(); this.toggleEmojiPicker();
} }
}, },
hideMentions() {
this.showMentions = false;
},
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
@@ -1103,6 +1197,13 @@ export default {
:message="inReplyTo" :message="inReplyTo"
@dismiss="resetReplyToMessage" @dismiss="resetReplyToMessage"
/> />
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput <EmojiInput
v-if="showEmojiPicker" v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker" v-on-clickaway="hideEmojiPicker"
@@ -1126,7 +1227,23 @@ export default {
@play="recordingAudioState = 'playing'" @play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'" @pause="recordingAudioState = 'paused'"
/> />
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor <WootMessageEditor
v-else
v-model="message" v-model="message"
:editor-id="editorStateId" :editor-id="editorStateId"
class="input popover-prosemirror-menu" class="input popover-prosemirror-menu"
@@ -1252,6 +1369,10 @@ export default {
.reply-box__top { .reply-box__top {
@apply relative py-0 px-4 -mt-px; @apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
} }
.emoji-dialog { .emoji-dialog {
@@ -1271,4 +1392,9 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2; @apply ltr:left-1 rtl:right-1 -bottom-2;
} }
} }
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style> </style>
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { ref, watch, computed, nextTick } from 'vue'; import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList'; import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
items: { items: {
@@ -16,8 +15,6 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']); const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null); const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0); const selectedIndex = ref(0);
@@ -97,7 +94,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex, 'text-n-slate-12': index === selectedIndex,
}" }"
> >
{{ getPlainText(item.description) }} {{ item.description }}
</p> </p>
<p <p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap" class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
@@ -1,31 +1,39 @@
import { selectTranslation } from '../useTranslations'; import { ref } from 'vue';
import { useTranslations } from '../useTranslations';
describe('selectTranslation', () => { describe('useTranslations', () => {
it('returns null when translations is null', () => { it('returns false and null when contentAttributes is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull(); const contentAttributes = ref(null);
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
}); });
it('returns null when translations is empty', () => { it('returns false and null when translations are missing', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull(); const contentAttributes = ref({});
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
}); });
it('returns first translation when no locale matches', () => { it('returns false and null when translations is an empty object', () => {
const translations = { en: 'Hello', es: 'Hola' }; const contentAttributes = ref({ translations: {} });
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello'); const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
}); });
it('returns translation matching agent locale', () => { it('returns true and correct translation content when translations exist', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' }; const contentAttributes = ref({
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola'); translations: { en: 'Hello' },
}); });
const { hasTranslations, translationContent } =
it('falls back to account locale when agent locale not found', () => { useTranslations(contentAttributes);
const translations = { en: 'Hello', zh_CN: '你好' }; expect(hasTranslations.value).toBe(true);
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好'); // Should return the first translation (en: 'Hello')
}); expect(translationContent.value).toBe('Hello');
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
}); });
}); });
@@ -1,25 +1,4 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/** /**
* Composable to extract translation state/content from contentAttributes. * Composable to extract translation state/content from contentAttributes.
@@ -27,9 +6,6 @@ export function selectTranslation(translations, agentLocale, accountLocale) {
* @returns {Object} { hasTranslations, translationContent } * @returns {Object} { hasTranslations, translationContent }
*/ */
export function useTranslations(contentAttributes) { export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => { const hasTranslations = computed(() => {
if (!contentAttributes.value) return false; if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value; const { translations = {} } = contentAttributes.value;
@@ -38,11 +14,8 @@ export function useTranslations(contentAttributes) {
const translationContent = computed(() => { const translationContent = computed(() => {
if (!hasTranslations.value) return null; if (!hasTranslations.value) return null;
return selectTranslation( const translations = contentAttributes.value.translations;
contentAttributes.value.translations, return translations[Object.keys(translations)[0]];
uiSettings.value?.locale,
currentAccount.value?.locale
);
}); });
return { hasTranslations, translationContent }; return { hasTranslations, translationContent };
+2 -6
View File
@@ -237,12 +237,8 @@ export const MARKDOWN_PATTERNS = [
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }], patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
}, },
{ {
type: 'link', // PM: link type: 'link', // PM: link, eg: [text](url)
patterns: [ patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
],
}, },
]; ];
@@ -5,5 +5,4 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme', COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed', DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo', MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
}; };
-1
View File
@@ -43,7 +43,6 @@ export const FEATURE_FLAGS = {
SAML: 'saml', SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply', QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies', COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
}; };
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
@@ -1,4 +1,4 @@
import * as amplitude from '@amplitude/analytics-browser'; import posthog from 'posthog-js';
/** /**
* AnalyticsHelper class to initialize and track user analytics * AnalyticsHelper class to initialize and track user analytics
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
return; return;
} }
amplitude.init(this.analyticsToken, { posthog.init(this.analyticsToken, {
defaultTracking: false, api_host: 'https://app.posthog.com',
capture_pageview: false,
persistence: 'localStorage+cookie',
}); });
this.analytics = amplitude; this.analytics = posthog;
} }
/** /**
@@ -43,26 +45,20 @@ export class AnalyticsHelper {
} }
this.user = user; this.user = user;
this.analytics.setUserId(`user-${this.user.id.toString()}`); this.analytics.identify(this.user.id.toString(), {
email: this.user.email,
const identifyEvent = new amplitude.Identify(); name: this.user.name,
identifyEvent.set('email', this.user.email); avatar: this.user.avatar_url,
identifyEvent.set('name', this.user.name); });
identifyEvent.set('avatar', this.user.avatar_url);
this.analytics.identify(identifyEvent);
const { accounts, account_id: accountId } = this.user; const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter( const [currentAccount] = accounts.filter(
account => account.id === accountId account => account.id === accountId
); );
if (currentAccount) { if (currentAccount) {
const groupId = `account-${currentAccount.id.toString()}`; this.analytics.group('company', currentAccount.id.toString(), {
name: currentAccount.name,
this.analytics.setGroup('company', groupId); });
const groupIdentify = new amplitude.Identify();
groupIdentify.set('name', currentAccount.name);
this.analytics.groupIdentify('company', groupId, groupIdentify);
} }
} }
@@ -76,21 +72,20 @@ export class AnalyticsHelper {
if (!this.analytics) { if (!this.analytics) {
return; return;
} }
this.analytics.track(eventName, properties); this.analytics.capture(eventName, properties);
} }
/** /**
* Track the page views * Track the page views
* @function * @function
* @param {string} pageName - Page name * @param {Object} params - Page view properties
* @param {Object} [properties={}] - Page view properties
*/ */
page(pageName, properties = {}) { page(params) {
if (!this.analytics) { if (!this.analytics) {
return; return;
} }
this.analytics.track('$pageview', { pageName, ...properties }); this.analytics.capture('$pageview', params);
} }
} }
@@ -1,15 +1,12 @@
import helperObject, { AnalyticsHelper } from '../'; import helperObject, { AnalyticsHelper } from '../';
vi.mock('@amplitude/analytics-browser', () => ({ vi.mock('posthog-js', () => ({
init: vi.fn(), default: {
setUserId: vi.fn(), init: vi.fn(),
identify: vi.fn(), identify: vi.fn(),
setGroup: vi.fn(), capture: vi.fn(),
groupIdentify: vi.fn(), group: vi.fn(),
track: vi.fn(), },
Identify: vi.fn(() => ({
set: vi.fn(),
})),
})); }));
describe('helperObject', () => { describe('helperObject', () => {
@@ -25,12 +22,12 @@ describe('AnalyticsHelper', () => {
}); });
describe('init', () => { describe('init', () => {
it('should initialize amplitude with the correct token', async () => { it('should initialize posthog with the correct token', async () => {
await analyticsHelper.init(); await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null); expect(analyticsHelper.analytics).not.toBe(null);
}); });
it('should not initialize amplitude if token is not provided', async () => { it('should not initialize posthog if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper(); analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init(); await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
@@ -39,15 +36,10 @@ describe('AnalyticsHelper', () => {
describe('identify', () => { describe('identify', () => {
beforeEach(() => { beforeEach(() => {
analyticsHelper.analytics = { analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
};
}); });
it('should call setUserId and identify on amplitude with correct arguments', () => { it('should call identify on posthog with correct arguments', () => {
analyticsHelper.identify({ analyticsHelper.identify({
id: 123, id: 123,
email: 'test@example.com', email: 'test@example.com',
@@ -57,18 +49,19 @@ describe('AnalyticsHelper', () => {
account_id: 1, account_id: 1,
}); });
expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith( expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
'user-123' email: 'test@example.com',
); name: 'Test User',
expect(analyticsHelper.analytics.identify).toHaveBeenCalled(); avatar: 'avatar_url',
expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith( });
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
'company', 'company',
'account-1' '1',
{ name: 'Account 1' }
); );
expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled();
}); });
it('should call identify on amplitude without group', () => { it('should call identify on posthog without group', () => {
analyticsHelper.identify({ analyticsHelper.identify({
id: 123, id: 123,
email: 'test@example.com', email: 'test@example.com',
@@ -78,10 +71,10 @@ describe('AnalyticsHelper', () => {
account_id: 5, account_id: 5,
}); });
expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled(); expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
}); });
it('should not call analytics methods if analytics is null', () => { it('should not call analytics.page if analytics is null', () => {
analyticsHelper.analytics = null; analyticsHelper.analytics = null;
analyticsHelper.identify({}); analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
@@ -90,27 +83,27 @@ describe('AnalyticsHelper', () => {
describe('track', () => { describe('track', () => {
beforeEach(() => { beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() }; analyticsHelper.analytics = { capture: vi.fn() };
analyticsHelper.user = { id: 123 }; analyticsHelper.user = { id: 123 };
}); });
it('should call track on amplitude with correct arguments', () => { it('should call capture on posthog with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' }); analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith( expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event', 'Test Event',
{ prop1: 'value1', prop2: 'value2' } { prop1: 'value1', prop2: 'value2' }
); );
}); });
it('should call track on amplitude with default properties', () => { it('should call capture on posthog with default properties', () => {
analyticsHelper.track('Test Event'); analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith( expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'Test Event', 'Test Event',
{} {}
); );
}); });
it('should not call track on amplitude if analytics is not initialized', () => { it('should not call capture on posthog if analytics is not initialized', () => {
analyticsHelper.analytics = null; analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' }); analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
@@ -119,25 +112,24 @@ describe('AnalyticsHelper', () => {
describe('page', () => { describe('page', () => {
beforeEach(() => { beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() }; analyticsHelper.analytics = { capture: vi.fn() };
}); });
it('should call the track method for pageview with the correct arguments', () => { it('should call the capture method for pageview with the correct arguments', () => {
const pageName = 'home'; const params = {
const properties = { name: 'Test page',
path: '/test', url: '/test',
name: 'home',
}; };
analyticsHelper.page(pageName, properties); analyticsHelper.page(params);
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith( expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
'$pageview', '$pageview',
{ pageName: 'home', path: '/test', name: 'home' } params
); );
}); });
it('should not call analytics.track if analytics is null', () => { it('should not call analytics.capture if analytics is null', () => {
analyticsHelper.analytics = null; analyticsHelper.analytics = null;
analyticsHelper.page('home'); analyticsHelper.page();
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
}); });
}); });
+45 -17
View File
@@ -34,7 +34,6 @@ export function extractTextFromMarkdown(markdown) {
/** /**
* Strip unsupported markdown formatting based on channel capabilities. * Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
* *
* @param {string} markdown - markdown text to process * @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting * @param {string} channelType - The channel type to check supported formatting
@@ -44,16 +43,35 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
if (!markdown) return ''; if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {}; const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const supported = [...marks, ...nodes]; const has = (arr, key) => arr.includes(key);
// Apply patterns from MARKDOWN_PATTERNS for unsupported types // Define stripping rules: [condition, pattern, replacement]
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => { const rules = [
if (supported.includes(type)) return text; [!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
return patterns.reduce( [!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
(t, { pattern, replacement }) => t.replace(pattern, replacement), [!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
text [!has(marks, 'code'), /`([^`]+)`/g, '$1'],
); [!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
}, markdown); [!has(marks, 'strong'), /__([^_]+)__/g, '$1'],
[!has(marks, 'em'), /\*([^*]+)\*/g, '$1'],
// Match _text_ only at word boundaries (whitespace/string start/end)
// Preserves underscores in URLs (e.g., https://example.com/path_name) and variable names
[
!has(marks, 'em'),
/(?<=^|[\s])_([^_\s][^_]*[^_\s]|[^_\s])_(?=$|[\s])/g,
'$1',
],
[!has(marks, 'strike'), /~~([^~]+)~~/g, '$1'],
[!has(nodes, 'blockquote'), /^>\s?/gm, ''],
[!has(nodes, 'bulletList'), /^[-*+]\s+/gm, ''],
[!has(nodes, 'orderedList'), /^\d+\.\s+/gm, ''],
];
const result = rules.reduce(
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
return result return result
.split('\n') .split('\n')
@@ -168,22 +186,27 @@ export function appendSignature(body, signature, channelType) {
/** /**
* Removes the signature from the body, along with the signature delimiter. * Removes the signature from the body, along with the signature delimiter.
* Tries multiple signature variants: original, channel-stripped, and fully stripped. * Tries to find both the original signature and the stripped version.
* *
* @param {string} body - The body to remove the signature from. * @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove. * @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping. * @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed. * @returns {string} - The body with the signature removed.
*/ */
export function removeSignature(body, signature, channelType) { export function removeSignature(body, signature, channelType) {
// Build unique list of signature variants to try // Build list of signatures to try: original, channel-stripped, and fully stripped
const cleanedSignature = cleanSignature(signature);
const channelStripped = channelType const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType)) ? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
: null; : null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [ const signaturesToTry = [
cleanSignature(signature), cleanedSignature,
channelStripped, channelStripped,
cleanSignature(extractTextFromMarkdown(signature)), fullyStripped,
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates ].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature // Find the first matching signature
@@ -202,12 +225,17 @@ export function removeSignature(body, signature, channelType) {
newBody = newBody.substring(0, signatureIndex).trimEnd(); newBody = newBody.substring(0, signatureIndex).trimEnd();
} }
// Remove delimiter if it's at the end // let's find the delimiter and remove it
if (newBody.endsWith(SIGNATURE_DELIMITER)) { const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// if the delimiter is at the end, remove it // if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length); newBody = newBody.substring(0, delimiterIndex);
} }
// return the value
return newBody; return newBody;
} }
@@ -1,5 +1,3 @@
import DOMPurify from 'dompurify';
// Quote detection strategies // Quote detection strategies
const QUOTE_INDICATORS = [ const QUOTE_INDICATORS = [
'.gmail_quote_container', '.gmail_quote_container',
@@ -31,7 +29,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) { static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML // Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent); tempDiv.innerHTML = htmlContent;
// Remove elements matching class selectors // Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => { QUOTE_INDICATORS.forEach(selector => {
@@ -58,7 +56,7 @@ export class EmailQuoteExtractor {
*/ */
static hasQuotes(htmlContent) { static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent); tempDiv.innerHTML = htmlContent;
// Check for class-based quotes // Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
@@ -20,7 +20,6 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks', webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing', billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml', saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
}; };
export function getHelpUrlForFeature(featureName) { export function getHelpUrlForFeature(featureName) {
@@ -1,5 +1,4 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns'; import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/** /**
* Extracts plain text from HTML content * Extracts plain text from HTML content
@@ -14,7 +13,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' '); return html.replace(/<[^>]*>/g, ' ');
} }
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(html); tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || ''; return tempDiv.textContent || tempDiv.innerText || '';
}; };
@@ -896,22 +896,6 @@ describe('stripUnsupportedFormatting', () => {
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
}); });
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves various URI scheme autolinks', () => {
const content =
'Email <mailto:user@example.com> or call <tel:+1234567890>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves email autolinks', () => {
const content = 'Contact us at <support@chatwoot.com>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => { it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second'; const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
@@ -983,38 +967,6 @@ describe('stripUnsupportedFormatting', () => {
).toBe('Check this link'); ).toBe('Check this link');
}); });
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts URI scheme autolinks to plain text', () => {
const content =
'Email <mailto:support@example.com> or call <tel:+1234567890>';
const expected =
'Email mailto:support@example.com or call tel:+1234567890';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts email autolinks to plain text', () => {
const content = 'Reach us at <admin@chatwoot.com> for help';
const expected = 'Reach us at admin@chatwoot.com for help';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles mixed autolink types', () => {
const content = 'Visit <https://example.com> or email <info@example.com>';
const expected = 'Visit https://example.com or email info@example.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => { it('strips bullet list markers', () => {
expect( expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema) stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
@@ -96,58 +96,4 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => { it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true); expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
}); });
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
}); });
@@ -33,26 +33,6 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1'); expect(result).toContain('Line 1');
expect(result).toContain('Line 2'); expect(result).toContain('Line 2');
}); });
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
}); });
describe('getEmailSenderName', () => { describe('getEmailSenderName', () => {
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at", "CREATED_AT": "Created at"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "All",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "From", "FROM": "from",
"EMAIL": "Email", "EMAIL": "email",
"EMAIL_SUBJECT": "Subject", "EMAIL_SUBJECT": "subject"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "الاسم", "NAME": "الاسم",
"DOMAIN": "النطاق", "DOMAIN": "النطاق",
"CREATED_AT": "تم إنشاؤها في", "CREATED_AT": "تم إنشاؤها في"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "النص" "TEXT": "النص"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "جهات الاتصال", "CONTACT": "جهات الاتصال",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "رسالة", "LABEL": "رسالة",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "اللغة",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "العودة للخلف"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "الكل",
"CONTACTS": "جهات الاتصال", "CONTACTS": "جهات الاتصال",
"CONVERSATIONS": "المحادثات", "CONVERSATIONS": "المحادثات",
"MESSAGES": "الرسائل", "MESSAGES": "الرسائل",
@@ -19,50 +19,14 @@
"LOADING_DATA": "جار التحميل", "LOADING_DATA": "جار التحميل",
"EMPTY_STATE": "لم يتم العثور على {item} للطلب '{query}'", "EMPTY_STATE": "لم يتم العثور على {item} للطلب '{query}'",
"EMPTY_STATE_FULL": "لم يتم العثور على نتائج للطلب '{query}'", "EMPTY_STATE_FULL": "لم يتم العثور على نتائج للطلب '{query}'",
"PLACEHOLDER_KEYBINDING": "/للتركيز", "PLACEHOLDER_KEYBINDING": "/ للتركيز",
"INPUT_PLACEHOLDER": "أكتب 3 أحرف أو أكثر للبحث", "INPUT_PLACEHOLDER": "أكتب 3 أحرف أو أكثر للبحث",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "البحث عن طريق معرف المحادثة أو البريد الإلكتروني أو رقم الهاتف أو الرسائل للحصول على نتائج بحث أفضل. ", "EMPTY_STATE_DEFAULT": "البحث عن طريق معرف المحادثة أو البريد الإلكتروني أو رقم الهاتف أو الرسائل للحصول على نتائج بحث أفضل. ",
"BOT_LABEL": "رد آلي", "BOT_LABEL": "رد آلي",
"READ_MORE": "اقرأ المزيد", "READ_MORE": "اقرأ المزيد",
"READ_LESS": "Read less",
"WROTE": "كتب:", "WROTE": "كتب:",
"FROM": "من", "FROM": "من",
"EMAIL": "البريد الإلكتروني", "EMAIL": "البريد الإلكتروني",
"EMAIL_SUBJECT": "الموضوع", "EMAIL_SUBJECT": "الموضوع"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "آخر 7 أيام",
"LAST_30_DAYS": "آخر 30 يوماً",
"LAST_60_DAYS": "آخر 60 يوماً",
"LAST_90_DAYS": "آخر 90 يوماً",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "و",
"APPLY": "تطبيق",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "المرسل",
"IN": "صندوق الوارد",
"AGENTS": "الوكلاء",
"CONTACTS": "جهات الاتصال",
"INBOXES": "قنوات التواصل",
"NO_AGENTS": "لم يتم العثور على وكلاء",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at", "CREATED_AT": "Created at"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "All",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "From", "FROM": "from",
"EMAIL": "Email", "EMAIL": "email",
"EMAIL_SUBJECT": "Subject", "EMAIL_SUBJECT": "subject"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Име", "NAME": "Име",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Създаден в", "CREATED_AT": "Създаден в"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Контакт", "CONTACT": "Контакт",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Съобщение", "LABEL": "Съобщение",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "Всички",
"CONTACTS": "Контакти", "CONTACTS": "Контакти",
"CONVERSATIONS": "Разговори", "CONVERSATIONS": "Разговори",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Бот", "BOT_LABEL": "Бот",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "From", "FROM": "от",
"EMAIL": "Email", "EMAIL": "имейл",
"EMAIL_SUBJECT": "Subject", "EMAIL_SUBJECT": "тема"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Входяща кутия",
"AGENTS": "Агенти",
"CONTACTS": "Контакти",
"INBOXES": "Inboxes",
"NO_AGENTS": "Няма намерени агенти",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at", "CREATED_AT": "Created at"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "All",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "From", "FROM": "from",
"EMAIL": "Email", "EMAIL": "email",
"EMAIL_SUBJECT": "Subject", "EMAIL_SUBJECT": "subject"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Nom", "NAME": "Nom",
"DOMAIN": "Domini", "DOMAIN": "Domini",
"CREATED_AT": "Creat per", "CREATED_AT": "Creat per"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Llista" "TEXT": "Llista"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contacte", "CONTACT": "Contacte",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Missatge", "LABEL": "Missatge",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Idioma",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Torna"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "Totes",
"CONTACTS": "Contactes", "CONTACTS": "Contactes",
"CONVERSATIONS": "Converses", "CONVERSATIONS": "Converses",
"MESSAGES": "Missatges", "MESSAGES": "Missatges",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Carregant", "LOADING_DATA": "Carregant",
"EMPTY_STATE": "No s'ha trobat cap {item} per a la consulta '{query}'", "EMPTY_STATE": "No s'ha trobat cap {item} per a la consulta '{query}'",
"EMPTY_STATE_FULL": "No s'han trobat resultats per a la consulta '{query}'", "EMPTY_STATE_FULL": "No s'han trobat resultats per a la consulta '{query}'",
"PLACEHOLDER_KEYBINDING": "/centrar", "PLACEHOLDER_KEYBINDING": "/ centrar",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Esborrar tot",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Llegir més", "READ_MORE": "Llegir més",
"READ_LESS": "Read less",
"WROTE": "va escriure:", "WROTE": "va escriure:",
"FROM": "Des de", "FROM": "des de",
"EMAIL": "Correu electrònic", "EMAIL": "correu electrònic",
"EMAIL_SUBJECT": "Assumpte", "EMAIL_SUBJECT": "assumpte"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Últims 7 dies",
"LAST_30_DAYS": "Últims 30 dies",
"LAST_60_DAYS": "Últims 60 dies",
"LAST_90_DAYS": "Últims 90 dies",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Aplica",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Esborra els filtres"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Remitent",
"IN": "Safata d'entrada",
"AGENTS": "Agents",
"CONTACTS": "Contactes",
"INBOXES": "Safates d'entrada",
"NO_AGENTS": "No s'han trobat agents",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Název", "NAME": "Název",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Vytvořeno", "CREATED_AT": "Vytvořeno"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Zpráva", "LABEL": "Zpráva",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "Vše",
"CONTACTS": "Kontakty", "CONTACTS": "Kontakty",
"CONVERSATIONS": "Konverzace", "CONVERSATIONS": "Konverzace",
"MESSAGES": "Zprávy", "MESSAGES": "Zprávy",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "Od", "FROM": "od",
"EMAIL": "E-mailová adresa", "EMAIL": "e-mailová adresa",
"EMAIL_SUBJECT": "Předmět", "EMAIL_SUBJECT": "předmět"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Posledních 7 dní",
"LAST_30_DAYS": "Posledních 30 dní",
"LAST_60_DAYS": "Posledních 60 dní",
"LAST_90_DAYS": "Posledních 90 dní",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Použít",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agenti",
"CONTACTS": "Kontakty",
"INBOXES": "Schránky",
"NO_AGENTS": "Nenalezeni žádní agenti",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Navn", "NAME": "Navn",
"DOMAIN": "Domæne", "DOMAIN": "Domæne",
"CREATED_AT": "Oprettet den", "CREATED_AT": "Oprettet den"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Tekst" "TEXT": "Tekst"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Kontakt", "CONTACT": "Kontakt",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Besked", "LABEL": "Besked",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Sprog",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Gå tilbage"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "Alle",
"CONTACTS": "Kontakter", "CONTACTS": "Kontakter",
"CONVERSATIONS": "Samtaler", "CONVERSATIONS": "Samtaler",
"MESSAGES": "Beskeder", "MESSAGES": "Beskeder",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "Fra", "FROM": "fra",
"EMAIL": "E-mail", "EMAIL": "e-mail",
"EMAIL_SUBJECT": "Emne", "EMAIL_SUBJECT": "emne"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Seneste 7 dage",
"LAST_30_DAYS": "Seneste 30 dage",
"LAST_60_DAYS": "Seneste 60 dage",
"LAST_90_DAYS": "Seneste 90 dage",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Anvend",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Afsender",
"IN": "Indbakke",
"AGENTS": "Agenter",
"CONTACTS": "Kontakter",
"INBOXES": "Indbakker",
"NO_AGENTS": "Ingen agenter fundet",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Erstellt am", "CREATED_AT": "Erstellt am"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Kontakt", "CONTACT": "Kontakt",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Eingehender Anruf",
"OUTGOING_CALL": "Ausgehender Anruf",
"CALL_IN_PROGRESS": "Anruf läuft",
"NOT_ANSWERED_YET": "Noch nicht beantwortet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Nachricht", "LABEL": "Nachricht",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Sprache",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Zurück"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "Alle",
"CONTACTS": "Kontakte", "CONTACTS": "Kontakte",
"CONVERSATIONS": "Gespräche", "CONVERSATIONS": "Gespräche",
"MESSAGES": "Nachrichten", "MESSAGES": "Nachrichten",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Wird geladen", "LOADING_DATA": "Wird geladen",
"EMPTY_STATE": "Keine {item} für Abfrage '{query} ' gefunden", "EMPTY_STATE": "Keine {item} für Abfrage '{query} ' gefunden",
"EMPTY_STATE_FULL": "Kein Ergebnis für Abfrage '{query} ' gefunden", "EMPTY_STATE_FULL": "Kein Ergebnis für Abfrage '{query} ' gefunden",
"PLACEHOLDER_KEYBINDING": "/fokussieren", "PLACEHOLDER_KEYBINDING": "/ fokussieren",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Alle löschen",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Mehr erfahren", "READ_MORE": "Mehr erfahren",
"READ_LESS": "Read less",
"WROTE": "schrieb:", "WROTE": "schrieb:",
"FROM": "Von", "FROM": "von",
"EMAIL": "E-Mail", "EMAIL": "e-Mail",
"EMAIL_SUBJECT": "Betreff", "EMAIL_SUBJECT": "betreff"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "erstellt am {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Letzte 7 Tage",
"LAST_30_DAYS": "Letzte 30 Tage",
"LAST_60_DAYS": "Letzten 60 Tage",
"LAST_90_DAYS": "Letzten 90 Tage",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "und",
"APPLY": "Übernehmen",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Filter löschen"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Absender",
"IN": "Posteingang",
"AGENTS": "Agenten",
"CONTACTS": "Kontakte",
"INBOXES": "Posteingänge",
"NO_AGENTS": "Keine Agenten gefunden",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Όνομα", "NAME": "Όνομα",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Δημιουργήθηκε στις", "CREATED_AT": "Δημιουργήθηκε στις"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -28,7 +28,6 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Κείμενο" "TEXT": "Κείμενο"
} }
}, },
@@ -275,16 +275,6 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Επαφές", "CONTACT": "Επαφές",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {
@@ -808,35 +808,6 @@
"LABEL": "Μήνυμα", "LABEL": "Μήνυμα",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Γλώσσα",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Πίσω"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -848,7 +819,6 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "Όλες",
"CONTACTS": "Επαφές", "CONTACTS": "Επαφές",
"CONVERSATIONS": "Συζητήσεις", "CONVERSATIONS": "Συζητήσεις",
"MESSAGES": "Μηνύματα", "MESSAGES": "Μηνύματα",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "Από", "FROM": "από",
"EMAIL": "Email", "EMAIL": "email",
"EMAIL_SUBJECT": "Θέμα", "EMAIL_SUBJECT": "θέμα"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Τελευταίες 7 ημέρες",
"LAST_30_DAYS": "Τελευταίες 30 ημέρες",
"LAST_60_DAYS": "Τελευταίες 60 ημέρες",
"LAST_90_DAYS": "Τελευταίες 90 ημέρες",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "και",
"APPLY": "Εφαρμογή",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Αποστολέας",
"IN": "Εισερχόμενα",
"AGENTS": "Πράκτορες",
"CONTACTS": "Επαφές",
"INBOXES": "Κιβώτια Εισερχομένων",
"NO_AGENTS": "Δεν βρέθηκαν Πράκτορες",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -6,8 +6,7 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at", "CREATED_AT": "Created at"
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {
@@ -7,7 +7,6 @@
}, },
"CLOSE": "Close", "CLOSE": "Close",
"BETA": "Beta", "BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.", "BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
"PREFERRED": "Preferred"
} }
} }
@@ -403,7 +403,6 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime" "CANCEL_ANYTIME": "You can change or cancel your plan anytime"
}, },
"ENTERPRISE_PAYWALL": { "ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI is only available in the Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.", "UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade." "ASK_ADMIN": "Please reach out to your administrator for the upgrade."
}, },
@@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All results", "ALL": "All",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@@ -19,50 +19,14 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/to focus", "PLACEHOLDER_KEYBINDING": "/ to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "From", "FROM": "from",
"EMAIL": "Email", "EMAIL": "email",
"EMAIL_SUBJECT": "Subject", "EMAIL_SUBJECT": "subject"
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }
@@ -378,56 +378,7 @@
"INFO_SHORT": "Automatically mark offline when you aren't using the app." "INFO_SHORT": "Automatically mark offline when you aren't using the app."
}, },
"DOCS": "Read docs", "DOCS": "Read docs",
"SECURITY": "Security", "SECURITY": "Security"
"CAPTAIN_AI": "Captain"
},
"CAPTAIN_SETTINGS": {
"TITLE": "Captain Settings",
"DESCRIPTION": "Configure your AI models and features for Captain. Captain follows a credit based billing, you will be charged credits for every action Captain takes based on the model selected.",
"LOADING": "Loading Captain configuration...",
"LINK_TEXT": "Learn more about Captain Credits",
"NOT_ENABLED": "Captain is not enabled for your account. Please upgrade your plan to access Captain features.",
"MODEL_CONFIG": {
"TITLE": "Model Configuration",
"DESCRIPTION": "Select AI models for different features.",
"SELECT_MODEL": "Select model",
"CREDITS_PER_MESSAGE": "{credits} credit/message",
"COMING_SOON": "Coming soon",
"EDITOR": {
"TITLE": "Editor Features",
"DESCRIPTION": "Powers smart compose, grammar corrections, tone adjustments, and content enhancement in your message editor."
},
"ASSISTANT": {
"TITLE": "Assistant",
"DESCRIPTION": "Handles automated responses, conversation summaries, and intelligent reply suggestions for customer interactions."
},
"COPILOT": {
"TITLE": "Co-pilot",
"DESCRIPTION": "Provides real-time contextual suggestions, knowledge base recommendations, and proactive insights during conversations."
}
},
"FEATURES": {
"TITLE": "Features",
"DESCRIPTION": "Enable or disable AI-powered features.",
"AUDIO_TRANSCRIPTION": {
"TITLE": "Audio Transcription",
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
},
"HELP_CENTER_SEARCH": {
"TITLE": "Help Center Search Indexing",
"DESCRIPTION": "Use AI for context aware search inside your help center articles."
},
"LABEL_SUGGESTION": {
"TITLE": "Label Suggestion",
"DESCRIPTION": "Automatically suggest relevant labels and tags for conversations based on content analysis and context.",
"MODEL_TITLE": "Label Suggestion Model",
"MODEL_DESCRIPTION": "Select the AI model to use for analyzing conversations and suggesting appropriate labels"
}
},
"API": {
"SUCCESS": "Captain settings updated successfully.",
"ERROR": "Failed to update Captain settings. Please try again."
}
}, },
"BILLING_SETTINGS": { "BILLING_SETTINGS": {
"TITLE": "Billing", "TITLE": "Billing",
@@ -132,7 +132,7 @@
}, },
"BADGES": { "BADGES": {
"PRE_CHAT": "Pre-chat", "PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolución" "RESOLUTION": "Resolution"
} }
} }
} }

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