Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0917e1a646 | ||
|
|
9407cc2ad5 | ||
|
|
ff68c3a74f | ||
|
|
58cec84b93 | ||
|
|
34b42a1ce1 | ||
|
|
ab83a663f0 | ||
|
|
b099d3a1eb | ||
|
|
d526cf283d | ||
|
|
a2e348df06 | ||
|
|
f5957e7970 | ||
|
|
8b230c6920 | ||
|
|
c7da5b4cde | ||
|
|
b0863ab1cd | ||
|
|
005a22fd69 | ||
|
|
ba3eb787e7 | ||
|
|
837026c146 | ||
|
|
ded2f2751a | ||
|
|
ed0e87405c | ||
|
|
92422f979d | ||
|
|
dcc72ee63c | ||
|
|
7ba7bf842e | ||
|
|
69be587729 | ||
|
|
59cbf57e20 | ||
|
|
566de02385 | ||
|
|
02ab856520 | ||
|
|
e58600d1b9 | ||
|
|
3e5b2979eb | ||
|
|
bd698cb12c | ||
|
|
79381a4c5f | ||
|
|
d57170712d | ||
|
|
e7be8c14bd | ||
|
|
8311657f9c | ||
|
|
62376701da | ||
|
|
d2e6d6aee3 | ||
|
|
53c21e6ad3 | ||
|
|
d408f664cb | ||
|
|
20d97be4c9 | ||
|
|
73140998ff | ||
|
|
6365a7ea53 | ||
|
|
2adc040a8f | ||
|
|
86da3f7c06 | ||
|
|
c22a31c198 | ||
|
|
4e9f644646 | ||
|
|
bfa8a8ed60 | ||
|
|
7ab60d9f9c | ||
|
|
faac1486e9 | ||
|
|
8340bd615c | ||
|
|
75d3569f22 | ||
|
|
545453537f | ||
|
|
d75702c6b2 | ||
|
|
b76ec878f1 | ||
|
|
a954e1eaca | ||
|
|
bc5f1722e1 | ||
|
|
8f39e62570 | ||
|
|
7520ca7a99 | ||
|
|
35f4e63605 | ||
|
|
5773089865 | ||
|
|
d01c7d3fa7 | ||
|
|
959d2c0d8c | ||
|
|
622f29a0b7 |
+47
-3
@@ -76,7 +76,7 @@ jobs:
|
||||
bundle install
|
||||
|
||||
- node/install:
|
||||
node-version: '23.7'
|
||||
node-version: '24.12'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '23.7'
|
||||
node-version: '24.12'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '23.7'
|
||||
node-version: '24.12'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@@ -218,6 +218,49 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
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
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
@@ -234,6 +277,7 @@ jobs:
|
||||
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
||||
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
||||
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
|
||||
|
||||
# Database setup
|
||||
- run:
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile.base
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '23.7.0'
|
||||
NODE_VERSION: '24.12.0'
|
||||
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.
|
||||
USER_UID: '1000'
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '23.7.0'
|
||||
NODE_VERSION: '24.12.0'
|
||||
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.
|
||||
USER_UID: '1000'
|
||||
|
||||
@@ -274,3 +274,5 @@ AZURE_APP_SECRET=
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 23
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: pnpm
|
||||
|
||||
+20
-15
@@ -140,24 +140,27 @@ GEM
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.760.0)
|
||||
aws-sdk-core (3.188.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1198.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.64.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.126.0)
|
||||
aws-sdk-core (~> 3, >= 3.174.0)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-sns (1.70.0)
|
||||
aws-sdk-core (~> 3, >= 3.188.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.5.2)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
@@ -438,7 +441,8 @@ GEM
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
httparty (0.21.0)
|
||||
httparty (0.24.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
@@ -552,7 +556,8 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.6.0)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
|
||||
@@ -8,7 +8,6 @@ ___
|
||||
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -137,4 +136,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>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.8.0
|
||||
4.9.2
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.3
|
||||
3.5.0
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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,4 +1,6 @@
|
||||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||
include AttachmentConcern
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||
|
||||
@@ -9,25 +11,32 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
def show; end
|
||||
|
||||
def create
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions]
|
||||
@automation_rule.actions = actions
|
||||
@automation_rule.conditions = params[:conditions]
|
||||
|
||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
|
||||
|
||||
@automation_rule.save!
|
||||
process_attachments
|
||||
@automation_rule
|
||||
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
automation_rule_update
|
||||
process_attachments
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@automation_rule.assign_attributes(automation_rules_permit)
|
||||
@automation_rule.actions = actions if params[:actions]
|
||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||
@automation_rule.save!
|
||||
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
||||
render_could_not_create_error(@automation_rule.errors.messages)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -43,29 +52,11 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||
return if actions.blank?
|
||||
|
||||
actions.each do |action|
|
||||
blob_id = action['action_params']
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@automation_rule.files.attach(blob)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def automation_rule_update
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||
@automation_rule.save!
|
||||
end
|
||||
|
||||
def automation_rules_permit
|
||||
params.permit(
|
||||
:name, :description, :event_name, :account_id, :active,
|
||||
:name, :description, :event_name, :active,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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
|
||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
|
||||
def permitted_params
|
||||
params.require(:twilio_channel).permit(
|
||||
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
include AttachmentConcern
|
||||
|
||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||
|
||||
@@ -11,26 +13,32 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.actions = params[:actions]
|
||||
@macro.actions = actions
|
||||
|
||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
|
||||
|
||||
@macro.save!
|
||||
process_attachments
|
||||
@macro
|
||||
blobs.each { |blob| @macro.files.attach(blob) }
|
||||
end
|
||||
|
||||
def update
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.assign_attributes(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
process_attachments
|
||||
@macro.actions = actions if params[:actions]
|
||||
@macro.save!
|
||||
blobs.each { |blob| @macro.files.attach(blob) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||
render_could_not_create_error(@macro.errors.messages)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,20 +55,9 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def process_attachments
|
||||
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||
return if actions.blank?
|
||||
|
||||
actions.each do |action|
|
||||
blob_id = action['action_params']
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@macro.files.attach(blob)
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(
|
||||
:name, :account_id, :visibility,
|
||||
:name, :visibility,
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def process_attached_logo
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||
@portal.logo.attach(blob)
|
||||
end
|
||||
|
||||
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
||||
search_type: search_type,
|
||||
params: params
|
||||
).perform
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def render_success(file_blob)
|
||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
|
||||
end
|
||||
|
||||
def render_error(message, status)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@@ -18,6 +18,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||
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
|
||||
|
||||
def check_authorization
|
||||
@@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
module AttachmentConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def validate_and_prepare_attachments(actions, record = nil)
|
||||
blobs = []
|
||||
return [blobs, actions, nil] if actions.blank?
|
||||
|
||||
sanitized = actions.map do |action|
|
||||
next action unless action[:action_name] == 'send_attachment'
|
||||
|
||||
result = process_attachment_action(action, record, blobs)
|
||||
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
[blobs, sanitized, nil]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_attachment_action(action, record, blobs)
|
||||
blob_id = action[:action_params].first
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||
|
||||
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||
return action if blob_already_attached?(record, blob_id)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def blob_already_attached?(record, blob_id)
|
||||
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
|
||||
CHATWOOT_INBOX_TOKEN
|
||||
API_CHANNEL_NAME
|
||||
API_CHANNEL_THUMBNAIL
|
||||
ANALYTICS_TOKEN
|
||||
CLOUD_ANALYTICS_TOKEN
|
||||
DIRECT_UPLOADS_ENABLED
|
||||
MAXIMUM_FILE_UPLOAD_SIZE
|
||||
HCAPTCHA_SITE_KEY
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* 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();
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Device } from '@twilio/voice-sdk';
|
||||
import VoiceAPI from './voiceAPIClient';
|
||||
|
||||
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
||||
|
||||
class TwilioVoiceClient extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.device = null;
|
||||
this.activeConnection = null;
|
||||
this.initialized = false;
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async initializeDevice(inboxId) {
|
||||
this.destroyDevice();
|
||||
|
||||
const response = await VoiceAPI.getToken(inboxId);
|
||||
const { token, account_id } = response || {};
|
||||
if (!token) throw new Error('Invalid token');
|
||||
|
||||
this.device = new Device(token, {
|
||||
allowIncomingWhileBusy: true,
|
||||
disableAudioContextSounds: true,
|
||||
appParams: { account_id },
|
||||
});
|
||||
|
||||
this.device.removeAllListeners();
|
||||
this.device.on('connect', conn => {
|
||||
this.activeConnection = conn;
|
||||
conn.on('disconnect', this.onDisconnect);
|
||||
});
|
||||
|
||||
this.device.on('disconnect', this.onDisconnect);
|
||||
|
||||
this.device.on('tokenWillExpire', async () => {
|
||||
const r = await VoiceAPI.getToken(this.inboxId);
|
||||
if (r?.token) this.device.updateToken(r.token);
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
this.inboxId = inboxId;
|
||||
|
||||
return this.device;
|
||||
}
|
||||
|
||||
get hasActiveConnection() {
|
||||
return !!this.activeConnection;
|
||||
}
|
||||
|
||||
endClientCall() {
|
||||
if (this.activeConnection) {
|
||||
this.activeConnection.disconnect();
|
||||
}
|
||||
this.activeConnection = null;
|
||||
if (this.device) {
|
||||
this.device.disconnectAll();
|
||||
}
|
||||
}
|
||||
|
||||
destroyDevice() {
|
||||
if (this.device) {
|
||||
this.device.destroy();
|
||||
}
|
||||
this.activeConnection = null;
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async joinClientCall({ to, conversationId }) {
|
||||
if (!this.device || !this.initialized || !to) return null;
|
||||
if (this.activeConnection) return this.activeConnection;
|
||||
|
||||
const params = {
|
||||
To: to,
|
||||
is_agent: 'true',
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
const connection = await this.device.connect({ params });
|
||||
this.activeConnection = connection;
|
||||
|
||||
connection.on('disconnect', this.onDisconnect);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
onDisconnect = () => {
|
||||
this.activeConnection = null;
|
||||
this.dispatchEvent(createCallDisconnectedEvent());
|
||||
};
|
||||
}
|
||||
|
||||
export default new TwilioVoiceClient();
|
||||
@@ -0,0 +1,40 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../../ApiClient';
|
||||
import ContactsAPI from '../../contacts';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('voice', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
initiateCall(contactId, inboxId) {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
leaveConference(inboxId, conversationId) {
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
params: { conversation_id: conversationId },
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
joinConference({ conversationId, inboxId, callSid }) {
|
||||
return axios
|
||||
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
getToken(inboxId) {
|
||||
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||
return axios
|
||||
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
||||
.then(r => r.data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
@@ -32,6 +32,16 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
createCSATTemplate(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
contacts({ q, page = 1 }) {
|
||||
contacts({ q, page = 1, since, until }) {
|
||||
return axios.get(`${this.url}/contacts`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
conversations({ q, page = 1 }) {
|
||||
conversations({ q, page = 1, since, until }) {
|
||||
return axios.get(`${this.url}/conversations`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
messages({ q, page = 1 }) {
|
||||
messages({ q, page = 1, since, until, from, inboxId }) {
|
||||
return axios.get(`${this.url}/messages`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
from,
|
||||
inbox_id: inboxId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
articles({ q, page = 1 }) {
|
||||
articles({ q, page = 1, since, until }) {
|
||||
return axios.get(`${this.url}/articles`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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>
|
||||
<div
|
||||
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-3 py-5"
|
||||
|
||||
+4
@@ -35,6 +35,10 @@ const sortMenus = [
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
|
||||
value: 'contacts_count',
|
||||
},
|
||||
];
|
||||
|
||||
const orderingMenus = [
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -67,6 +68,17 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(response?.conversation_id);
|
||||
} catch (error) {
|
||||
|
||||
+7
-4
@@ -171,10 +171,13 @@ const onPaste = e => {
|
||||
const files = e.clipboardData?.files;
|
||||
if (!files?.length) return;
|
||||
|
||||
Array.from(files).forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
});
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(files)
|
||||
.filter(file => file.size > 0)
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
});
|
||||
};
|
||||
|
||||
useEventListener(document, 'paste', onPaste);
|
||||
|
||||
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
|
||||
solid:
|
||||
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
|
||||
faded:
|
||||
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
|
||||
outline:
|
||||
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
|
||||
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
@@ -37,9 +38,13 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disableLocalFiltering: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action']);
|
||||
const emit = defineEmits(['action', 'search']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -57,6 +62,7 @@ const flattenedMenuItems = computed(() => {
|
||||
});
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (props.disableLocalFiltering) return props.menuItems;
|
||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||
|
||||
return flattenedMenuItems.value.filter(item =>
|
||||
@@ -69,7 +75,7 @@ const filteredMenuSections = computed(() => {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!searchQuery.value) {
|
||||
if (props.disableLocalFiltering || !searchQuery.value) {
|
||||
return props.menuSections;
|
||||
}
|
||||
|
||||
@@ -89,6 +95,12 @@ const filteredMenuSections = computed(() => {
|
||||
.filter(section => section.items.length > 0);
|
||||
});
|
||||
|
||||
const handleSearchInput = event => {
|
||||
if (props.disableLocalFiltering) {
|
||||
emit('search', event.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
@@ -118,7 +130,7 @@ onMounted(() => {
|
||||
>
|
||||
<div
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
@@ -130,6 +142,7 @@ onMounted(() => {
|
||||
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"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,10 +154,23 @@ onMounted(() => {
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||
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="showSearch ? 'top-10' : 'top-0'"
|
||||
>
|
||||
{{ section.title }}
|
||||
</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
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
@@ -235,5 +261,6 @@ onMounted(() => {
|
||||
: t('DROPDOWN_MENU.EMPTY_STATE')
|
||||
}}
|
||||
</div>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,7 +42,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['retry']);
|
||||
|
||||
const allMessages = computed(() => {
|
||||
return useCamelCase(props.messages, { deep: true });
|
||||
return useCamelCase(props.messages, {
|
||||
deep: true,
|
||||
stopPaths: ['content_attributes.translations'],
|
||||
});
|
||||
});
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
buttonText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,6 +17,10 @@ const { attachment } = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showTranscribedText: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
@@ -182,7 +186,7 @@ const downloadAudio = async () => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="attachment.transcribedText"
|
||||
v-if="attachment.transcribedText && showTranscribedText"
|
||||
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
|
||||
>
|
||||
{{ attachment.transcribedText }}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
<script setup>
|
||||
import { h, computed, onMounted } from 'vue';
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
@@ -55,14 +54,7 @@ const toggleShortcutModalFn = show => {
|
||||
|
||||
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
|
||||
|
||||
// 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 expandedItem = ref(null);
|
||||
|
||||
const setExpandedItem = name => {
|
||||
expandedItem.value = expandedItem.value === name ? null : name;
|
||||
@@ -493,6 +485,12 @@ const menuItems = computed(() => {
|
||||
icon: 'i-lucide-briefcase',
|
||||
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',
|
||||
label: t('SIDEBAR.AGENTS'),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, nextTick } from 'vue';
|
||||
import { computed, onMounted, watch, nextTick } from 'vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
@@ -126,6 +126,16 @@ onMounted(async () => {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
hasActiveChild,
|
||||
hasNewActiveChild => {
|
||||
if (hasNewActiveChild && !isExpanded.value) {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { computed, ref, onMounted, nextTick, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -31,20 +31,24 @@ const enableTransition = ref(false);
|
||||
const activeElement = computed(() => tabRefs.value[activeTab.value]);
|
||||
|
||||
const updateIndicator = () => {
|
||||
if (!activeElement.value) return;
|
||||
nextTick(() => {
|
||||
if (!activeElement.value) return;
|
||||
|
||||
indicatorStyle.value = {
|
||||
left: `${activeElement.value.offsetLeft}px`,
|
||||
width: `${activeElement.value.offsetWidth}px`,
|
||||
};
|
||||
indicatorStyle.value = {
|
||||
left: `${activeElement.value.offsetLeft}px`,
|
||||
width: `${activeElement.value.offsetWidth}px`,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
useResizeObserver(activeElement, () => {
|
||||
if (enableTransition.value || !activeElement.value) updateIndicator();
|
||||
useResizeObserver(activeElement, updateIndicator);
|
||||
|
||||
// Watch for prop/tabs changes to update indicator position
|
||||
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updateIndicator();
|
||||
nextTick(() => {
|
||||
enableTransition.value = true;
|
||||
});
|
||||
@@ -66,7 +70,7 @@ const showDivider = index => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
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]"
|
||||
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]"
|
||||
>
|
||||
<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"
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import { watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
joinCall,
|
||||
endCall: endCallSession,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
formattedCallDuration,
|
||||
} = useCallSession();
|
||||
|
||||
const getCallInfo = call => {
|
||||
const conversation = store.getters.getConversationById(call?.conversationId);
|
||||
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
|
||||
const sender = conversation?.meta?.sender;
|
||||
return {
|
||||
conversation,
|
||||
inbox,
|
||||
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
|
||||
inboxName: inbox?.name || 'Customer support',
|
||||
avatar: sender?.avatar || sender?.thumbnail,
|
||||
};
|
||||
};
|
||||
|
||||
const handleEndCall = async () => {
|
||||
const call = activeCall.value;
|
||||
if (!call) return;
|
||||
|
||||
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
|
||||
if (!inboxId) return;
|
||||
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
});
|
||||
};
|
||||
|
||||
const handleJoinCall = async call => {
|
||||
const { conversation } = getCallInfo(call);
|
||||
if (!call || !conversation || isJoining.value) return;
|
||||
|
||||
// End current active call before joining new one
|
||||
if (hasActiveCall.value) {
|
||||
await handleEndCall();
|
||||
}
|
||||
|
||||
const result = await joinCall({
|
||||
conversationId: call.conversationId,
|
||||
inboxId: conversation.inbox_id,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: call.conversationId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-join outbound calls when window is visible
|
||||
watch(
|
||||
() => incomingCalls.value[0],
|
||||
call => {
|
||||
if (
|
||||
call?.callDirection === 'outbound' &&
|
||||
!hasActiveCall.value &&
|
||||
WindowVisibilityHelper.isWindowVisible()
|
||||
) {
|
||||
handleJoinCall(call);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="incomingCalls.length || hasActiveCall"
|
||||
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
|
||||
>
|
||||
<!-- Incoming Calls (shown above active call) -->
|
||||
<div
|
||||
v-for="call in hasActiveCall ? incomingCalls : []"
|
||||
:key="call.callSid"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
|
||||
<Avatar
|
||||
:src="getCallInfo(call).avatar"
|
||||
:name="getCallInfo(call).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(call).contactName }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 truncate">
|
||||
{{ getCallInfo(call).inboxName }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="dismissCall(call.callSid)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(call)"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Call Widget -->
|
||||
<div
|
||||
v-if="hasActiveCall || incomingCalls.length"
|
||||
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
|
||||
>
|
||||
<div
|
||||
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
|
||||
:class="{ 'animate-pulse': !hasActiveCall }"
|
||||
>
|
||||
<Avatar
|
||||
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
|
||||
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
|
||||
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
|
||||
</p>
|
||||
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
|
||||
{{ formattedCallDuration }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-n-slate-11">
|
||||
{{
|
||||
incomingCalls[0]?.callDirection === 'outbound'
|
||||
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
|
||||
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
|
||||
@click="
|
||||
hasActiveCall
|
||||
? handleEndCall()
|
||||
: rejectIncomingCall(incomingCalls[0]?.callSid)
|
||||
"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-x-bold" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!hasActiveCall"
|
||||
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
|
||||
@click="handleJoinCall(incomingCalls[0])"
|
||||
>
|
||||
<i class="text-lg text-white i-ph-phone-bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
getSelectionCoords,
|
||||
calculateMenuPosition,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedFormatting,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -131,8 +132,10 @@ const editorMenuOptions = computed(() => {
|
||||
|
||||
const createState = (content, placeholder, plugins = [], methods = {}) => {
|
||||
const schema = editorSchema.value;
|
||||
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
|
||||
const sanitizedContent = stripUnsupportedFormatting(content, schema);
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(schema).parse(content),
|
||||
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
|
||||
plugins: buildEditor({
|
||||
schema,
|
||||
placeholder,
|
||||
@@ -676,11 +679,18 @@ function createEditorView() {
|
||||
typingIndicator.stop();
|
||||
emit('blur');
|
||||
},
|
||||
paste: (_view, event) => {
|
||||
paste: (view, event) => {
|
||||
if (props.disabled) return;
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
event.preventDefault();
|
||||
const { files } = event.clipboardData;
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
|
||||
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
|
||||
// while ReplyBox filters and handles valid file attachments
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
if (text) {
|
||||
view.dispatch(view.state.tr.insertText(text));
|
||||
emitOnChange();
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -852,6 +862,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
||||
max-height: none !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
> .ProseMirror {
|
||||
|
||||
+3
@@ -41,6 +41,9 @@ const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ import { useTrack } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ReplyToMessage from './ReplyToMessage.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
@@ -46,7 +44,6 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
@@ -72,8 +69,6 @@ export default {
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
QuotedEmailPreview,
|
||||
ResizableTextArea,
|
||||
CannedResponse,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -114,8 +109,6 @@ export default {
|
||||
recordingAudioState: '',
|
||||
recordingAudioDurationText: '',
|
||||
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||
mentionSearchKey: '',
|
||||
hasSlashCommand: false,
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
toEmails: '',
|
||||
@@ -148,9 +141,6 @@ export default {
|
||||
if (!senderId) return {};
|
||||
return this.$store.getters['contacts/getContact'](senderId);
|
||||
},
|
||||
isRichEditorEnabled() {
|
||||
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
|
||||
},
|
||||
shouldShowReplyToMessage() {
|
||||
return (
|
||||
this.inReplyTo?.id &&
|
||||
@@ -409,19 +399,6 @@ export default {
|
||||
!!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: {
|
||||
currentChat(conversation, oldConversation) {
|
||||
@@ -464,25 +441,7 @@ export default {
|
||||
this.resetRecorderAndClearAttachments();
|
||||
}
|
||||
},
|
||||
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)
|
||||
: '';
|
||||
|
||||
message() {
|
||||
// Autosave the current message draft.
|
||||
this.doAutoSaveDraft();
|
||||
},
|
||||
@@ -532,20 +491,14 @@ export default {
|
||||
methods: {
|
||||
handleInsert(article) {
|
||||
const { url, title } = article;
|
||||
if (this.isRichEditorEnabled) {
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
} else {
|
||||
this.addIntoEditor(
|
||||
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
|
||||
);
|
||||
}
|
||||
// Removing empty lines from the title
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
|
||||
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
|
||||
},
|
||||
@@ -614,26 +567,14 @@ export default {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
)
|
||||
: removeSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
@@ -649,7 +590,6 @@ export default {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.hideEmojiPicker();
|
||||
this.hideMentions();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
@@ -692,20 +632,15 @@ export default {
|
||||
},
|
||||
onPaste(e) {
|
||||
// Don't handle paste if compose new conversation modal is open
|
||||
if (this.newConversationModalActive) {
|
||||
return;
|
||||
}
|
||||
const data = e.clipboardData.files;
|
||||
if (!this.showRichContentEditor && data.length !== 0) {
|
||||
this.$refs.messageInput?.$el?.blur();
|
||||
}
|
||||
if (!data.length || !data[0]) {
|
||||
return;
|
||||
}
|
||||
data.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file: file });
|
||||
});
|
||||
if (this.newConversationModalActive) return;
|
||||
|
||||
// Filter valid files (non-zero size)
|
||||
Array.from(e.clipboardData.files)
|
||||
.filter(file => file.size > 0)
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
this.onFileUpload({ name, type, size, file });
|
||||
});
|
||||
},
|
||||
toggleUserMention(currentMentionState) {
|
||||
this.showUserMentions = currentMentionState;
|
||||
@@ -832,19 +767,15 @@ export default {
|
||||
// if signature is enabled, append it to the message
|
||||
// appendSignature ensures that the signature is not duplicated
|
||||
// so we don't need to check if the signature is already present
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
message = appendSignature(message, this.signatureToApply);
|
||||
}
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
message = appendSignature(
|
||||
message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
@@ -868,52 +799,30 @@ export default {
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
return;
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
}
|
||||
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||
},
|
||||
clearEditorSelection() {
|
||||
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) {
|
||||
if (this.showRichContentEditor) {
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
}
|
||||
if (!this.showRichContentEditor) {
|
||||
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||
}
|
||||
this.updateEditorSelectionWith = content;
|
||||
this.onFocus();
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
if (this.showRichContentEditor) {
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
} else {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
const effectiveChannelType = getEffectiveChannelType(
|
||||
this.channelType,
|
||||
this.inbox?.medium || ''
|
||||
);
|
||||
this.message = appendSignature(
|
||||
this.message,
|
||||
this.messageSignature,
|
||||
effectiveChannelType
|
||||
);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
@@ -948,9 +857,6 @@ export default {
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
hideMentions() {
|
||||
this.showMentions = false;
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
@@ -1197,13 +1103,6 @@ export default {
|
||||
:message="inReplyTo"
|
||||
@dismiss="resetReplyToMessage"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="showMentions && hasSlashCommand"
|
||||
v-on-clickaway="hideMentions"
|
||||
class="normal-editor__canned-box"
|
||||
:search-key="mentionSearchKey"
|
||||
@replace="replaceText"
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
@@ -1227,23 +1126,7 @@ export default {
|
||||
@play="recordingAudioState = 'playing'"
|
||||
@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
|
||||
v-else
|
||||
v-model="message"
|
||||
:editor-id="editorStateId"
|
||||
class="input popover-prosemirror-menu"
|
||||
@@ -1369,10 +1252,6 @@ export default {
|
||||
|
||||
.reply-box__top {
|
||||
@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 {
|
||||
@@ -1392,9 +1271,4 @@ export default {
|
||||
@apply ltr:left-1 rtl:right-1 -bottom-2;
|
||||
}
|
||||
}
|
||||
|
||||
.normal-editor__canned-box {
|
||||
width: calc(100% - 2 * 1rem);
|
||||
left: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch, computed, nextTick } from 'vue';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
@@ -15,6 +16,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['mentionSelect']);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const mentionsListContainerRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
@@ -94,7 +97,7 @@ const variableKey = (item = {}) => {
|
||||
'text-n-slate-12': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.description }}
|
||||
{{ getPlainText(item.description) }}
|
||||
</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"
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { ref } from 'vue';
|
||||
import { useTranslations } from '../useTranslations';
|
||||
import { selectTranslation } from '../useTranslations';
|
||||
|
||||
describe('useTranslations', () => {
|
||||
it('returns false and null when contentAttributes is null', () => {
|
||||
const contentAttributes = ref(null);
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
describe('selectTranslation', () => {
|
||||
it('returns null when translations is null', () => {
|
||||
expect(selectTranslation(null, 'en', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations are missing', () => {
|
||||
const contentAttributes = ref({});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
it('returns null when translations is empty', () => {
|
||||
expect(selectTranslation({}, 'en', 'en')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false and null when translations is an empty object', () => {
|
||||
const contentAttributes = ref({ translations: {} });
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(false);
|
||||
expect(translationContent.value).toBeNull();
|
||||
it('returns first translation when no locale matches', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola' };
|
||||
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
|
||||
});
|
||||
|
||||
it('returns true and correct translation content when translations exist', () => {
|
||||
const contentAttributes = ref({
|
||||
translations: { en: 'Hello' },
|
||||
});
|
||||
const { hasTranslations, translationContent } =
|
||||
useTranslations(contentAttributes);
|
||||
expect(hasTranslations.value).toBe(true);
|
||||
// Should return the first translation (en: 'Hello')
|
||||
expect(translationContent.value).toBe('Hello');
|
||||
it('returns translation matching agent locale', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
|
||||
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
|
||||
});
|
||||
|
||||
it('falls back to account locale when agent locale not found', () => {
|
||||
const translations = { en: 'Hello', zh_CN: '你好' };
|
||||
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
|
||||
});
|
||||
|
||||
it('returns first translation when both locales are undefined', () => {
|
||||
const translations = { en: 'Hello', es: 'Hola' };
|
||||
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
callDuration.value = elapsed;
|
||||
});
|
||||
|
||||
const activeCall = computed(() => callsStore.activeCall);
|
||||
const incomingCalls = computed(() => callsStore.incomingCalls);
|
||||
const hasActiveCall = computed(() => callsStore.hasActiveCall);
|
||||
|
||||
watch(
|
||||
hasActiveCall,
|
||||
active => {
|
||||
if (active) {
|
||||
durationTimer.start();
|
||||
} else {
|
||||
durationTimer.stop();
|
||||
callDuration.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
TwilioVoiceClient.addEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
durationTimer.stop();
|
||||
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
|
||||
callsStore.clearActiveCall()
|
||||
);
|
||||
});
|
||||
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
};
|
||||
|
||||
const joinCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
if (isJoining.value) return null;
|
||||
|
||||
isJoining.value = true;
|
||||
try {
|
||||
const device = await TwilioVoiceClient.initializeDevice(inboxId);
|
||||
if (!device) return null;
|
||||
|
||||
const joinResponse = await VoiceAPI.joinConference({
|
||||
conversationId,
|
||||
inboxId,
|
||||
callSid,
|
||||
});
|
||||
|
||||
await TwilioVoiceClient.joinClientCall({
|
||||
to: joinResponse?.conference_sid,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
durationTimer.start();
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
} finally {
|
||||
isJoining.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const rejectIncomingCall = callSid => {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
const dismissCall = callSid => {
|
||||
callsStore.dismissCall(callSid);
|
||||
};
|
||||
|
||||
const formattedCallDuration = computed(() => {
|
||||
const minutes = Math.floor(callDuration.value / 60);
|
||||
const seconds = callDuration.value % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
return {
|
||||
activeCall,
|
||||
incomingCalls,
|
||||
hasActiveCall,
|
||||
isJoining,
|
||||
formattedCallDuration,
|
||||
joinCall,
|
||||
endCall,
|
||||
rejectIncomingCall,
|
||||
dismissCall,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,25 @@
|
||||
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.
|
||||
@@ -6,6 +27,9 @@ import { computed } from 'vue';
|
||||
* @returns {Object} { hasTranslations, translationContent }
|
||||
*/
|
||||
export function useTranslations(contentAttributes) {
|
||||
const { uiSettings } = useUISettings();
|
||||
const { currentAccount } = useAccount();
|
||||
|
||||
const hasTranslations = computed(() => {
|
||||
if (!contentAttributes.value) return false;
|
||||
const { translations = {} } = contentAttributes.value;
|
||||
@@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
|
||||
|
||||
const translationContent = computed(() => {
|
||||
if (!hasTranslations.value) return null;
|
||||
const translations = contentAttributes.value.translations;
|
||||
return translations[Object.keys(translations)[0]];
|
||||
return selectTranslation(
|
||||
contentAttributes.value.translations,
|
||||
uiSettings.value?.locale,
|
||||
currentAccount.value?.locale
|
||||
);
|
||||
});
|
||||
|
||||
return { hasTranslations, translationContent };
|
||||
|
||||
@@ -140,6 +140,11 @@ export const FORMATTING = {
|
||||
nodes: [],
|
||||
menu: ['strong', 'em', 'link', 'undo', 'redo'],
|
||||
},
|
||||
'Context::Plain': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
};
|
||||
|
||||
// Editor menu options for Full Editor
|
||||
@@ -232,8 +237,12 @@ export const MARKDOWN_PATTERNS = [
|
||||
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
|
||||
},
|
||||
{
|
||||
type: 'link', // PM: link, eg: [text](url)
|
||||
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }],
|
||||
type: 'link', // PM: link
|
||||
patterns: [
|
||||
{ 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,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
|
||||
MESSAGE_REPLY_TO: 'messageReplyTo',
|
||||
RECENT_SEARCHES: 'recentSearches',
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export const FEATURE_FLAGS = {
|
||||
SAML: 'saml',
|
||||
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
|
||||
COMPANIES: 'companies',
|
||||
ADVANCED_SEARCH: 'advanced_search',
|
||||
};
|
||||
|
||||
export const PREMIUM_FEATURES = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import posthog from 'posthog-js';
|
||||
import * as amplitude from '@amplitude/analytics-browser';
|
||||
|
||||
/**
|
||||
* AnalyticsHelper class to initialize and track user analytics
|
||||
@@ -26,12 +26,10 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
posthog.init(this.analyticsToken, {
|
||||
api_host: 'https://app.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
amplitude.init(this.analyticsToken, {
|
||||
defaultTracking: false,
|
||||
});
|
||||
this.analytics = posthog;
|
||||
this.analytics = amplitude;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,20 +43,26 @@ export class AnalyticsHelper {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.id.toString(), {
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
});
|
||||
this.analytics.setUserId(`user-${this.user.id.toString()}`);
|
||||
|
||||
const identifyEvent = new amplitude.Identify();
|
||||
identifyEvent.set('email', this.user.email);
|
||||
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 [currentAccount] = accounts.filter(
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group('company', currentAccount.id.toString(), {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
const groupId = `account-${currentAccount.id.toString()}`;
|
||||
|
||||
this.analytics.setGroup('company', groupId);
|
||||
|
||||
const groupIdentify = new amplitude.Identify();
|
||||
groupIdentify.set('name', currentAccount.name);
|
||||
this.analytics.groupIdentify('company', groupId, groupIdentify);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,20 +76,21 @@ export class AnalyticsHelper {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
this.analytics.capture(eventName, properties);
|
||||
this.analytics.track(eventName, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the page views
|
||||
* @function
|
||||
* @param {Object} params - Page view properties
|
||||
* @param {string} pageName - Page name
|
||||
* @param {Object} [properties={}] - Page view properties
|
||||
*/
|
||||
page(params) {
|
||||
page(pageName, properties = {}) {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.capture('$pageview', params);
|
||||
this.analytics.track('$pageview', { pageName, ...properties });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
init: vi.fn(),
|
||||
setUserId: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
setGroup: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
Identify: vi.fn(() => ({
|
||||
set: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('helperObject', () => {
|
||||
@@ -22,12 +25,12 @@ describe('AnalyticsHelper', () => {
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize posthog with the correct token', async () => {
|
||||
it('should initialize amplitude with the correct token', async () => {
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not initialize posthog if token is not provided', async () => {
|
||||
it('should not initialize amplitude if token is not provided', async () => {
|
||||
analyticsHelper = new AnalyticsHelper();
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -36,10 +39,15 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('identify', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
analyticsHelper.analytics = {
|
||||
setUserId: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
setGroup: vi.fn(),
|
||||
groupIdentify: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should call identify on posthog with correct arguments', () => {
|
||||
it('should call setUserId and identify on amplitude with correct arguments', () => {
|
||||
analyticsHelper.identify({
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
@@ -49,19 +57,18 @@ describe('AnalyticsHelper', () => {
|
||||
account_id: 1,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
});
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'1',
|
||||
{ name: 'Account 1' }
|
||||
expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith(
|
||||
'user-123'
|
||||
);
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalled();
|
||||
expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'account-1'
|
||||
);
|
||||
expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call identify on posthog without group', () => {
|
||||
it('should call identify on amplitude without group', () => {
|
||||
analyticsHelper.identify({
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
@@ -71,10 +78,10 @@ describe('AnalyticsHelper', () => {
|
||||
account_id: 5,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
|
||||
expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call analytics.page if analytics is null', () => {
|
||||
it('should not call analytics methods if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.identify({});
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -83,27 +90,27 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: 123 };
|
||||
});
|
||||
|
||||
it('should call capture on posthog with correct arguments', () => {
|
||||
it('should call track on amplitude with correct arguments', () => {
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{ prop1: 'value1', prop2: 'value2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should call capture on posthog with default properties', () => {
|
||||
it('should call track on amplitude with default properties', () => {
|
||||
analyticsHelper.track('Test Event');
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call capture on posthog if analytics is not initialized', () => {
|
||||
it('should not call track on amplitude if analytics is not initialized', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -112,24 +119,25 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the capture method for pageview with the correct arguments', () => {
|
||||
const params = {
|
||||
name: 'Test page',
|
||||
url: '/test',
|
||||
it('should call the track method for pageview with the correct arguments', () => {
|
||||
const pageName = 'home';
|
||||
const properties = {
|
||||
path: '/test',
|
||||
name: 'home',
|
||||
};
|
||||
analyticsHelper.page(params);
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
analyticsHelper.page(pageName, properties);
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
params
|
||||
{ pageName: 'home', path: '/test', name: 'home' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call analytics.capture if analytics is null', () => {
|
||||
it('should not call analytics.track if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.page();
|
||||
analyticsHelper.page('home');
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
export default class Timer {
|
||||
constructor(onTick = null) {
|
||||
this.elapsed = 0;
|
||||
this.intervalId = null;
|
||||
this.onTick = onTick;
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
this.elapsed = 0;
|
||||
this.intervalId = setInterval(() => {
|
||||
this.elapsed += 1;
|
||||
if (this.onTick) {
|
||||
this.onTick(this.elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
this.elapsed = 0;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export function extractTextFromMarkdown(markdown) {
|
||||
|
||||
/**
|
||||
* Strip unsupported markdown formatting based on channel capabilities.
|
||||
* Uses MARKDOWN_PATTERNS from editor constants.
|
||||
*
|
||||
* @param {string} markdown - markdown text to process
|
||||
* @param {string} channelType - The channel type to check supported formatting
|
||||
@@ -43,35 +44,16 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
if (!markdown) return '';
|
||||
|
||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||
const has = (arr, key) => arr.includes(key);
|
||||
const supported = [...marks, ...nodes];
|
||||
|
||||
// Define stripping rules: [condition, pattern, replacement]
|
||||
const rules = [
|
||||
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''],
|
||||
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'],
|
||||
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''],
|
||||
[!has(marks, 'code'), /`([^`]+)`/g, '$1'],
|
||||
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'],
|
||||
[!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
|
||||
);
|
||||
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
|
||||
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
|
||||
if (supported.includes(type)) return text;
|
||||
return patterns.reduce(
|
||||
(t, { pattern, replacement }) => t.replace(pattern, replacement),
|
||||
text
|
||||
);
|
||||
}, markdown);
|
||||
|
||||
return result
|
||||
.split('\n')
|
||||
@@ -186,27 +168,22 @@ export function appendSignature(body, signature, channelType) {
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
* Tries to find both the original signature and the stripped version.
|
||||
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
|
||||
*
|
||||
* @param {string} body - The body to remove the signature from.
|
||||
* @param {string} signature - The signature to remove.
|
||||
* @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.
|
||||
*/
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build list of signatures to try: original, channel-stripped, and fully stripped
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
// Build unique list of signature variants to try
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
: null;
|
||||
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
|
||||
|
||||
// Try signatures in order: original → channel-specific → fully stripped
|
||||
const signaturesToTry = [
|
||||
cleanedSignature,
|
||||
cleanSignature(signature),
|
||||
channelStripped,
|
||||
fullyStripped,
|
||||
cleanSignature(extractTextFromMarkdown(signature)),
|
||||
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
|
||||
|
||||
// Find the first matching signature
|
||||
@@ -225,17 +202,12 @@ export function removeSignature(body, signature, channelType) {
|
||||
newBody = newBody.substring(0, signatureIndex).trimEnd();
|
||||
}
|
||||
|
||||
// let's find the delimiter and remove it
|
||||
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
|
||||
if (
|
||||
delimiterIndex !== -1 &&
|
||||
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
|
||||
) {
|
||||
// Remove delimiter if it's at the end
|
||||
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
|
||||
// if the delimiter is at the end, remove it
|
||||
newBody = newBody.substring(0, delimiterIndex);
|
||||
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
|
||||
}
|
||||
|
||||
// return the value
|
||||
return newBody;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
captain_billing: 'https://chwt.app/hc/captain_billing',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Extracts plain text from HTML content
|
||||
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
|
||||
return html.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(html);
|
||||
return tempDiv.textContent || tempDiv.innerText || '';
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import Timer from '../Timer';
|
||||
|
||||
describe('Timer', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('initializes with elapsed 0 and no interval', () => {
|
||||
const timer = new Timer();
|
||||
expect(timer.elapsed).toBe(0);
|
||||
expect(timer.intervalId).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts an onTick callback', () => {
|
||||
const onTick = vi.fn();
|
||||
const timer = new Timer(onTick);
|
||||
expect(timer.onTick).toBe(onTick);
|
||||
});
|
||||
});
|
||||
|
||||
describe('start', () => {
|
||||
it('starts the timer and increments elapsed every second', () => {
|
||||
const timer = new Timer();
|
||||
timer.start();
|
||||
|
||||
expect(timer.elapsed).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(timer.elapsed).toBe(1);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(timer.elapsed).toBe(2);
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(timer.elapsed).toBe(5);
|
||||
});
|
||||
|
||||
it('calls onTick callback with elapsed value', () => {
|
||||
const onTick = vi.fn();
|
||||
const timer = new Timer(onTick);
|
||||
timer.start();
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onTick).toHaveBeenCalledWith(1);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(onTick).toHaveBeenCalledWith(2);
|
||||
|
||||
expect(onTick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('resets elapsed to 0 when restarted', () => {
|
||||
const timer = new Timer();
|
||||
timer.start();
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(timer.elapsed).toBe(5);
|
||||
|
||||
timer.start();
|
||||
expect(timer.elapsed).toBe(0);
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(timer.elapsed).toBe(2);
|
||||
});
|
||||
|
||||
it('clears previous interval when restarted', () => {
|
||||
const timer = new Timer();
|
||||
timer.start();
|
||||
const firstIntervalId = timer.intervalId;
|
||||
|
||||
timer.start();
|
||||
expect(timer.intervalId).not.toBe(firstIntervalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('stops the timer and resets elapsed to 0', () => {
|
||||
const timer = new Timer();
|
||||
timer.start();
|
||||
|
||||
vi.advanceTimersByTime(3000);
|
||||
expect(timer.elapsed).toBe(3);
|
||||
|
||||
timer.stop();
|
||||
expect(timer.elapsed).toBe(0);
|
||||
expect(timer.intervalId).toBeNull();
|
||||
});
|
||||
|
||||
it('prevents further increments after stopping', () => {
|
||||
const timer = new Timer();
|
||||
timer.start();
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
timer.stop();
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(timer.elapsed).toBe(0);
|
||||
});
|
||||
|
||||
it('handles stop when timer is not running', () => {
|
||||
const timer = new Timer();
|
||||
expect(() => timer.stop()).not.toThrow();
|
||||
expect(timer.elapsed).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -896,6 +896,22 @@ describe('stripUnsupportedFormatting', () => {
|
||||
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', () => {
|
||||
const content = '- item 1\n- item 2\n1. first\n2. second';
|
||||
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
|
||||
@@ -967,6 +983,38 @@ describe('stripUnsupportedFormatting', () => {
|
||||
).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', () => {
|
||||
expect(
|
||||
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)
|
||||
|
||||
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
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,6 +33,26 @@ describe('quotedEmailHelper', () => {
|
||||
expect(result).toContain('Line 1');
|
||||
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', () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import types from 'dashboard/store/mutation-types';
|
||||
|
||||
export const TERMINAL_STATUSES = [
|
||||
'completed',
|
||||
'busy',
|
||||
'failed',
|
||||
'no-answer',
|
||||
'canceled',
|
||||
'missed',
|
||||
'ended',
|
||||
];
|
||||
|
||||
export const isInbound = direction => direction === 'inbound';
|
||||
|
||||
const isVoiceCallMessage = message => {
|
||||
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
|
||||
};
|
||||
|
||||
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
return {
|
||||
callSid: contentData.call_sid,
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
conversationId: message?.conversation_id,
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
callDirection,
|
||||
senderId,
|
||||
});
|
||||
}
|
||||
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
|
||||
if (isNewCall) {
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
callDirection,
|
||||
senderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
"CREATED_AT": "Created at",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} 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",
|
||||
"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. ",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "from",
|
||||
"EMAIL": "email",
|
||||
"EMAIL_SUBJECT": "subject"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"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,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "الاسم",
|
||||
"DOMAIN": "النطاق",
|
||||
"CREATED_AT": "تم إنشاؤها في"
|
||||
"CREATED_AT": "تم إنشاؤها في",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "النص"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "جهات الاتصال",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "رسالة",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "الكل",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "جهات الاتصال",
|
||||
"CONVERSATIONS": "المحادثات",
|
||||
"MESSAGES": "الرسائل",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "جار التحميل",
|
||||
"EMPTY_STATE": "لم يتم العثور على {item} للطلب '{query}'",
|
||||
"EMPTY_STATE_FULL": "لم يتم العثور على نتائج للطلب '{query}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ للتركيز",
|
||||
"PLACEHOLDER_KEYBINDING": "/للتركيز",
|
||||
"INPUT_PLACEHOLDER": "أكتب 3 أحرف أو أكثر للبحث",
|
||||
"RECENT_SEARCHES": "Recent searches",
|
||||
"CLEAR_ALL": "Clear all",
|
||||
"MOST_RECENT": "Most recent",
|
||||
"EMPTY_STATE_DEFAULT": "البحث عن طريق معرف المحادثة أو البريد الإلكتروني أو رقم الهاتف أو الرسائل للحصول على نتائج بحث أفضل. ",
|
||||
"BOT_LABEL": "رد آلي",
|
||||
"READ_MORE": "اقرأ المزيد",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "كتب:",
|
||||
"FROM": "من",
|
||||
"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,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
"CREATED_AT": "Created at",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} 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",
|
||||
"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. ",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "from",
|
||||
"EMAIL": "email",
|
||||
"EMAIL_SUBJECT": "subject"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"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,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Име",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Създаден в"
|
||||
"CREATED_AT": "Създаден в",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Контакт",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Съобщение",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "Всички",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Контакти",
|
||||
"CONVERSATIONS": "Разговори",
|
||||
"MESSAGES": "Messages",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} 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",
|
||||
"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.",
|
||||
"BOT_LABEL": "Бот",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "от",
|
||||
"EMAIL": "имейл",
|
||||
"EMAIL_SUBJECT": "тема"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"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": "Входяща кутия",
|
||||
"AGENTS": "Агенти",
|
||||
"CONTACTS": "Контакти",
|
||||
"INBOXES": "Inboxes",
|
||||
"NO_AGENTS": "Няма намерени агенти",
|
||||
"NO_CONTACTS": "Start by searching to see results",
|
||||
"NO_INBOXES": "No inboxes found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Name",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Created at"
|
||||
"CREATED_AT": "Created at",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Message",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "All",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contacts",
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"MESSAGES": "Messages",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} 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",
|
||||
"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. ",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "from",
|
||||
"EMAIL": "email",
|
||||
"EMAIL_SUBJECT": "subject"
|
||||
"FROM": "From",
|
||||
"EMAIL": "Email",
|
||||
"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,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Nom",
|
||||
"DOMAIN": "Domini",
|
||||
"CREATED_AT": "Creat per"
|
||||
"CREATED_AT": "Creat per",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Llista"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contacte",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Missatge",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "Totes",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Contactes",
|
||||
"CONVERSATIONS": "Converses",
|
||||
"MESSAGES": "Missatges",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Carregant",
|
||||
"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}'",
|
||||
"PLACEHOLDER_KEYBINDING": "/ centrar",
|
||||
"PLACEHOLDER_KEYBINDING": "/centrar",
|
||||
"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.",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Llegir més",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "va escriure:",
|
||||
"FROM": "des de",
|
||||
"EMAIL": "correu electrònic",
|
||||
"EMAIL_SUBJECT": "assumpte"
|
||||
"FROM": "Des de",
|
||||
"EMAIL": "Correu electrònic",
|
||||
"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,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Název",
|
||||
"DOMAIN": "Domain",
|
||||
"CREATED_AT": "Vytvořeno"
|
||||
"CREATED_AT": "Vytvořeno",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"CALL_TO_ACTION": "Call to Action",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -275,6 +275,16 @@
|
||||
"SIDEBAR": {
|
||||
"CONTACT": "Contact",
|
||||
"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": {
|
||||
|
||||
@@ -808,6 +808,35 @@
|
||||
"LABEL": "Zpráva",
|
||||
"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": {
|
||||
"LABEL": "Survey rule",
|
||||
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
|
||||
@@ -819,6 +848,7 @@
|
||||
"SELECT_PLACEHOLDER": "select labels"
|
||||
},
|
||||
"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": {
|
||||
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
|
||||
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"SEARCH": {
|
||||
"TABS": {
|
||||
"ALL": "Vše",
|
||||
"ALL": "All results",
|
||||
"CONTACTS": "Kontakty",
|
||||
"CONVERSATIONS": "Konverzace",
|
||||
"MESSAGES": "Zprávy",
|
||||
@@ -19,14 +19,50 @@
|
||||
"LOADING_DATA": "Loading",
|
||||
"EMPTY_STATE": "No {item} 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",
|
||||
"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.",
|
||||
"BOT_LABEL": "Bot",
|
||||
"READ_MORE": "Read more",
|
||||
"READ_LESS": "Read less",
|
||||
"WROTE": "wrote:",
|
||||
"FROM": "od",
|
||||
"EMAIL": "e-mailová adresa",
|
||||
"EMAIL_SUBJECT": "předmět"
|
||||
"FROM": "Od",
|
||||
"EMAIL": "E-mailová adresa",
|
||||
"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,7 +6,8 @@
|
||||
"OPTIONS": {
|
||||
"NAME": "Navn",
|
||||
"DOMAIN": "Domæne",
|
||||
"CREATED_AT": "Oprettet den"
|
||||
"CREATED_AT": "Oprettet den",
|
||||
"CONTACTS_COUNT": "Contacts count"
|
||||
}
|
||||
},
|
||||
"ORDER": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user