Merge pull request #188 from fazer-ai/chore/merge-upstream-4.10

Chore/merge upstream 4.10
This commit is contained in:
Gabriel Jablonski 2026-01-16 14:37:49 -03:00 committed by GitHub
commit 8fe6ea54bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
618 changed files with 16331 additions and 2486 deletions

View File

@ -76,7 +76,7 @@ jobs:
bundle install bundle install
- node/install: - node/install:
node-version: '23.7' node-version: '24.13'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
@ -117,7 +117,7 @@ jobs:
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '23.7' node-version: '24.13'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
@ -148,7 +148,7 @@ jobs:
steps: steps:
- checkout - checkout
- node/install: - node/install:
node-version: '23.7' node-version: '24.13'
- node/install-pnpm - node/install-pnpm
- node/install-packages: - node/install-packages:
pkg-manager: pnpm pkg-manager: pnpm
@ -218,6 +218,49 @@ jobs:
source ~/.rvm/scripts/rvm source ~/.rvm/scripts/rvm
bundle install bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database # Configure environment and database
- run: - run:
name: Database Setup and Configure Environment Variables name: Database Setup and Configure Environment Variables
@ -234,6 +277,7 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env" echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup # Database setup
- run: - run:

View File

@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base dockerfile: .devcontainer/Dockerfile.base
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0' NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'

View File

@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile dockerfile: .devcontainer/Dockerfile
args: args:
VARIANT: 'ubuntu-22.04' VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0' NODE_VERSION: '24.13.0'
RUBY_VERSION: '3.4.4' RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000. # On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000' USER_UID: '1000'

View File

@ -275,6 +275,8 @@ AZURE_APP_SECRET=
# contact_inboxes with no conversation older than 90 days will be removed # contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# Baileys API Whatsapp provider # Baileys API Whatsapp provider
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025 BAILEYS_PROVIDER_DEFAULT_URL=http://localhost:3025

View File

@ -24,7 +24,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 24
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies

View File

@ -26,7 +26,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 24
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
run: pnpm i run: pnpm i
@ -41,7 +41,7 @@ jobs:
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 24
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies
run: pnpm i run: pnpm i
@ -92,7 +92,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 24
cache: 'pnpm' cache: 'pnpm'
- name: Install pnpm dependencies - name: Install pnpm dependencies

View File

@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 23 node-version: 24
cache: 'pnpm' cache: 'pnpm'
- name: pnpm - name: pnpm

2
.nvmrc
View File

@ -1 +1 @@
23.7.0 24.13.0

View File

@ -441,7 +441,8 @@ GEM
http-cookie (1.0.5) http-cookie (1.0.5)
domain_name (~> 0.5) domain_name (~> 0.5)
http-form_data (2.3.0) http-form_data (2.3.0)
httparty (0.21.0) httparty (0.24.0)
csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
httpclient (2.8.3) httpclient (2.8.3)
@ -555,7 +556,8 @@ GEM
ruby2_keywords ruby2_keywords
msgpack (1.8.0) msgpack (1.8.0)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.3.0) multipart-post (2.3.0)
mutex_m (0.3.0) mutex_m (0.3.0)
neighbor (0.2.3) neighbor (0.2.3)

View File

@ -8,7 +8,6 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc. The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p> <p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge"> <img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a> <a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@ -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> <a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License. *Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.

View File

@ -1 +1 @@
4.8.0 4.10.0

View File

@ -1 +1 @@
3.4.3 3.5.0

View File

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

View File

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

View File

@ -50,3 +50,5 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1 @current_page = params[:page] || 1
end end
end end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')

View File

@ -1,38 +1,27 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
DEFAULT_LANGUAGE = 'en'.freeze
before_action :fetch_inbox before_action :fetch_inbox
before_action :validate_whatsapp_channel before_action :validate_whatsapp_channel
def show def show
template = @inbox.csat_config&.dig('template') service = CsatTemplateManagementService.new(@inbox)
return render json: { template_exists: false } unless template result = service.template_status
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id) if result[:service_error]
status_result = @inbox.channel.provider_service.get_template_status(template_name) render json: { error: result[:service_error] }, status: :internal_server_error
else
render_template_status_response(status_result, template_name) render json: result
rescue StandardError => e end
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end end
def create def create
template_params = extract_template_params template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank? return render_missing_message_error if template_params[:message].blank?
# Delete existing template even though we are using a new one. service = CsatTemplateManagementService.new(@inbox)
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails. result = service.create_template(template_params)
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
render_template_creation_result(result) render_template_creation_result(result)
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
rescue StandardError => e
Rails.logger.error "Error creating CSAT template: #{e.message}"
render json: { error: 'Template creation failed' }, status: :internal_server_error
end end
private private
@ -43,9 +32,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end end
def validate_whatsapp_channel def validate_whatsapp_channel
return if @inbox.whatsapp? return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp channels' }, render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
status: :bad_request status: :bad_request
end end
@ -57,35 +46,36 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity render json: { error: 'Message is required' }, status: :unprocessable_entity
end end
def create_template_via_provider(template_params)
template_config = {
message: template_params[:message],
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
language: template_params[:language] || DEFAULT_LANGUAGE,
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
}
@inbox.channel.provider_service.create_csat_template(template_config)
end
def render_template_creation_result(result) def render_template_creation_result(result)
if result[:success] if result[:success]
render_successful_template_creation(result) render_successful_template_creation(result)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else else
render_failed_template_creation(result) render_failed_template_creation(result)
end end
end end
def render_successful_template_creation(result) def render_successful_template_creation(result)
render json: { if @inbox.twilio_whatsapp?
template: { render json: {
name: result[:template_name], template: {
template_id: result[:template_id], friendly_name: result[:friendly_name],
status: 'PENDING', content_sid: result[:content_sid],
language: result[:language] || DEFAULT_LANGUAGE status: result[:status] || 'pending',
} language: result[:language] || 'en'
}, status: :created }
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
end end
def render_failed_template_creation(result) def render_failed_template_creation(result)
@ -98,45 +88,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity }, status: :unprocessable_entity
end end
def delete_existing_template_if_needed
template = @inbox.csat_config&.dig('template')
return true if template.blank?
template_name = template['name']
return true if template_name.blank?
template_status = @inbox.channel.provider_service.get_template_status(template_name)
return true unless template_status[:success]
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
if deletion_result[:success]
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
true
else
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
false
end
rescue StandardError => e
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
false
end
def render_template_status_response(status_result, template_name)
if status_result[:success]
render json: {
template_exists: true,
template_name: template_name,
status: status_result[:template][:status],
template_id: status_result[:template][:id]
}
else
render json: {
template_exists: false,
error: 'Template not found'
}
end
end
def parse_whatsapp_error(response_body) def parse_whatsapp_error(response_body)
return { user_message: nil, technical_details: nil } if response_body.blank? return { user_message: nil, technical_details: nil } if response_body.blank?

View File

@ -214,7 +214,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController #
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language, { csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }], { survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :created_at, :language] }] }] template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
end end
def permitted_params(channel_attributes = []) def permitted_params(channel_attributes = [])

View File

@ -28,5 +28,7 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type, search_type: search_type,
params: params params: params
).perform ).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end end
end end

View File

@ -38,6 +38,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams') generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end end
def conversations_summary
@report_data = generate_conversations_report
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
end
def conversation_traffic def conversation_traffic
@report_data = generate_conversations_heatmap_report @report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f timezone_offset = (params[:timezone_offset] || 0).to_f

View File

@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
def agent def agent
render_report_with(V2::Reports::AgentSummaryBuilder) 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) render_report_with(V2::Reports::LabelSummaryBuilder)
end end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private private
def check_authorization def check_authorization
@ -40,4 +46,12 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params def permitted_params
params.permit(:since, :until, :business_hours) params.permit(:since, :until, :business_hours)
end end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end end

View File

@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL API_CHANNEL_THUMBNAIL
ANALYTICS_TOKEN CLOUD_ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY HCAPTCHA_SITE_KEY

View File

@ -46,6 +46,13 @@ module Api::V2::Accounts::ReportsHelper
end end
end end
def generate_conversations_report
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
summary = builder.summary
[generate_conversation_report_metrics(summary)]
end
private private
def build_params(base_params) def build_params(base_params)
@ -71,4 +78,16 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count] report[:resolved_conversations_count]
] ]
end end
def generate_conversation_report_metrics(summary)
[
summary[:conversations_count],
summary[:incoming_messages_count],
summary[:outgoing_messages_count],
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
summary[:resolutions_count],
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
]
end
end end

View File

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

View File

@ -33,6 +33,16 @@ class Inboxes extends CacheEnabledApiClient {
return axios.post(`${this.url}/${inboxId}/sync_templates`); 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`);
}
setupChannelProvider(inboxId) { setupChannelProvider(inboxId) {
return axios.post(`${this.url}/${inboxId}/setup_channel_provider`); return axios.post(`${this.url}/${inboxId}/setup_channel_provider`);
} }

View File

@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
}); });
} }
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/conversations_summary`, {
params: { since, until, business_hours: businessHours },
});
}
getConversationTrafficCSV({ daysBefore = 6 } = {}) { getConversationTrafficCSV({ daysBefore = 6 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, { return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore }, params: { timezone_offset: getTimeOffset(), days_before: daysBefore },

View File

@ -14,38 +14,48 @@ class SearchAPI extends ApiClient {
}); });
} }
contacts({ q, page = 1 }) { contacts({ q, page = 1, since, until }) {
return axios.get(`${this.url}/contacts`, { return axios.get(`${this.url}/contacts`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
}, },
}); });
} }
conversations({ q, page = 1 }) { conversations({ q, page = 1, since, until }) {
return axios.get(`${this.url}/conversations`, { return axios.get(`${this.url}/conversations`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
}, },
}); });
} }
messages({ q, page = 1 }) { messages({ q, page = 1, since, until, from, inboxId }) {
return axios.get(`${this.url}/messages`, { return axios.get(`${this.url}/messages`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
from,
inbox_id: inboxId,
}, },
}); });
} }
articles({ q, page = 1 }) { articles({ q, page = 1, since, until }) {
return axios.get(`${this.url}/articles`, { return axios.get(`${this.url}/articles`, {
params: { params: {
q, q,
page: page, page: page,
since,
until,
}, },
}); });
} }

View File

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

View File

@ -19,7 +19,7 @@ const handleClick = () => {
<template> <template>
<div <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 <div
class="flex w-full gap-3 py-5" class="flex w-full gap-3 py-5"

View File

@ -35,6 +35,10 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'), label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at', value: 'created_at',
}, },
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
},
]; ];
const orderingMenus = [ const orderingMenus = [

View File

@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { type: String, default: '' }, modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' }, label: { type: String, default: '' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false }, focusOnMount: { type: Boolean, default: false },
@ -96,6 +97,7 @@ watch(
]" ]"
> >
<WootEditor <WootEditor
:editor-id="editorKey"
:model-value="modelValue" :model-value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
:focus-on-mount="focusOnMount" :focus-on-mount="focusOnMount"
@ -152,6 +154,13 @@ watch(
} }
} }
} }
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
} }
} }
} }

View File

@ -46,6 +46,12 @@ const emit = defineEmits([
const { t } = useI18n(); const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null); const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false); const isEmojiPickerOpen = ref(false);
@ -177,10 +183,14 @@ const onPaste = e => {
const files = e.clipboardData?.files; const files = e.clipboardData?.files;
if (!files?.length) return; if (!files?.length) return;
Array.from(files).forEach(file => { // Filter valid files (non-zero size)
const { name, type, size } = file; Array.from(files)
onFileUpload({ file, name, type, size }); .filter(file => file.size > 0)
}); .forEach(file => {
const { name, type, size } = file;
// Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
});
}; };
useEventListener(document, 'paste', onPaste); useEventListener(document, 'paste', onPaste);

View File

@ -4,9 +4,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators'; import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { import {
appendSignature,
removeSignature,
getEffectiveChannelType, getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { import {
buildContactableInboxesList, buildContactableInboxesList,
@ -47,6 +46,8 @@ const emit = defineEmits([
'createConversation', 'createConversation',
]); ]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false); const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false); const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false); const showCcEmailsDropdown = ref(false);
@ -93,12 +94,6 @@ const whatsappMessageTemplates = computed(() =>
const inboxChannelType = computed(() => props.targetInbox?.channelType || ''); const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const inboxMedium = computed(() => props.targetInbox?.medium || '');
const effectiveChannelType = computed(() =>
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
);
const validationRules = computed(() => ({ const validationRules = computed(() => ({
selectedContact: { required }, selectedContact: { required },
targetInbox: { required }, targetInbox: { required },
@ -133,6 +128,8 @@ const newMessagePayload = () => {
currentUser: props.currentUser, currentUser: props.currentUser,
attachedFiles, attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled, directUploadsEnabled: props.isDirectUploadsEnabled,
sendWithSignature: props.sendWithSignature,
messageSignature: props.messageSignature,
}); });
}; };
@ -204,36 +201,36 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true; showInboxesDropdown.value = true;
}; };
const handleInboxAction = ({ value, action, ...rest }) => { const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
v$.value.$reset(); v$.value.$reset();
state.message = '';
emit('updateTargetInbox', { ...rest }); // Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
showInboxesDropdown.value = false; showInboxesDropdown.value = false;
state.attachedFiles = []; state.attachedFiles = [];
}; };
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
if (props.messageSignature) {
state.message = removeSignature(
state.message,
props.messageSignature,
effectiveChannelType.value
);
}
};
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage();
state.message = ''; stripMessageFormatting(DEFAULT_FORMATTING);
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
const clearSelectedContact = () => { const clearSelectedContact = () => {
removeSignatureFromMessage();
emit('clearSelectedContact'); emit('clearSelectedContact');
state.message = ''; state.message = '';
state.attachedFiles = []; state.attachedFiles = [];
@ -243,22 +240,6 @@ const onClickInsertEmoji = emoji => {
state.message += emoji; state.message += emoji;
}; };
const handleAddSignature = signature => {
state.message = appendSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(
state.message,
signature,
effectiveChannelType.value
);
};
const handleAttachFile = files => { const handleAttachFile = files => {
state.attachedFiles = files; state.attachedFiles = files;
}; };
@ -332,67 +313,68 @@ const shouldShowMessageEditor = computed(() => {
<template> <template>
<div <div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0" class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
> >
<ContactSelector <div class="flex-1 overflow-y-auto divide-y divide-n-strong">
:contacts="contacts" <ContactSelector
:selected-contact="selectedContact" :contacts="contacts"
:show-contacts-dropdown="showContactsDropdown" :selected-contact="selectedContact"
:is-loading="isLoading" :show-contacts-dropdown="showContactsDropdown"
:is-creating-contact="isCreatingContact" :is-loading="isLoading"
:contact-id="contactId" :is-creating-contact="isCreatingContact"
:contactable-inboxes-list="contactableInboxesList" :contact-id="contactId"
:show-inboxes-dropdown="showInboxesDropdown" :contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isContactInvalid" :show-inboxes-dropdown="showInboxesDropdown"
@search-contacts="handleContactSearch" :has-errors="validationStates.isContactInvalid"
@set-selected-contact="setSelectedContact" @search-contacts="handleContactSearch"
@clear-selected-contact="clearSelectedContact" @set-selected-contact="setSelectedContact"
@update-dropdown="handleDropdownUpdate" @clear-selected-contact="clearSelectedContact"
/> @update-dropdown="handleDropdownUpdate"
<InboxEmptyState v-if="showNoInboxAlert" /> />
<InboxSelector <InboxEmptyState v-if="showNoInboxAlert" />
v-else <InboxSelector
:target-inbox="targetInbox" v-else
:selected-contact="selectedContact" :target-inbox="targetInbox"
:show-inboxes-dropdown="showInboxesDropdown" :selected-contact="selectedContact"
:contactable-inboxes-list="contactableInboxesList" :show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isInboxInvalid" :contactable-inboxes-list="contactableInboxesList"
@update-inbox="removeTargetInbox" :has-errors="validationStates.isInboxInvalid"
@toggle-dropdown="showInboxesDropdown = $event" @update-inbox="removeTargetInbox"
@handle-inbox-action="handleInboxAction" @toggle-dropdown="showInboxesDropdown = $event"
/> @handle-inbox-action="handleInboxAction"
/>
<EmailOptions <EmailOptions
v-if="inboxTypes.isEmail" v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails" v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails" v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject" v-model:subject="state.subject"
:contacts="contacts" :contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown" :show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown" :show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading" :is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid" :has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails" @search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails" @search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate" @update-dropdown="handleDropdownUpdate"
/> />
<MessageEditor <MessageEditor
v-if="shouldShowMessageEditor" v-if="shouldShowMessageEditor"
v-model="state.message" v-model="state.message"
:message-signature="messageSignature" :message-signature="messageSignature"
:send-with-signature="sendWithSignature" :send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0" :channel-type="inboxChannelType"
:channel-type="inboxChannelType" :medium="targetInbox?.medium || ''"
:medium="targetInbox?.medium || ''" />
/>
<AttachmentPreviews <AttachmentPreviews
v-if="state.attachedFiles.length > 0" v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles" :attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event" @update:attachments="state.attachedFiles = $event"
/> />
</div>
<ActionButtons <ActionButtons
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
@ -412,8 +394,6 @@ const shouldShowMessageEditor = computed(() => {
:is-dropdown-active="isAnyDropdownActive" :is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature" :message-signature="messageSignature"
@insert-emoji="onClickInsertEmoji" @insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile" @attach-file="handleAttachFile"
@discard="$emit('discard')" @discard="$emit('discard')"
@send-message="handleSendMessage" @send-message="handleSendMessage"

View File

@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu <DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown" v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList" :menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5" class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)" @action="emit('handleInboxAction', $event)"
/> />
</div> </div>

View File

@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({ const props = defineProps({
hasErrors: { type: Boolean, default: false }, hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' }, channelType: { type: String, default: '' },
@ -24,14 +23,14 @@ const modelValue = defineModel({
</script> </script>
<template> <template>
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']"> <div class="flex-1 h-full">
<Editor <Editor
:key="editorKey"
v-model="modelValue" v-model="modelValue"
:editor-key="editorKey"
:placeholder=" :placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER') t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
" "
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]" class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
:class=" :class="
hasErrors hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9' ? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'

View File

@ -1,5 +1,6 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { getInboxIconByType } from 'dashboard/helper/inbox'; import { getInboxIconByType } from 'dashboard/helper/inbox';
import { appendSignature } from 'dashboard/helper/editorHelper';
import camelcaseKeys from 'camelcase-keys'; import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts'; import ContactAPI from 'dashboard/api/contacts';
@ -129,12 +130,29 @@ export const prepareNewMessagePayload = ({
currentUser, currentUser,
attachedFiles = [], attachedFiles = [],
directUploadsEnabled = false, directUploadsEnabled = false,
sendWithSignature = false,
messageSignature = '',
}) => { }) => {
let finalMessage = message;
if (sendWithSignature && messageSignature) {
const { signature_position, signature_separator } =
currentUser?.ui_settings || {};
const signatureSettings = {
position: signature_position || 'top',
separator: signature_separator || 'blank',
};
finalMessage = appendSignature(
message,
messageSignature,
signatureSettings
);
}
const payload = { const payload = {
inboxId: targetInbox.id, inboxId: targetInbox.id,
sourceId: targetInbox.sourceId, sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id), contactId: Number(selectedContact.id),
message: { content: message }, message: { content: finalMessage },
assigneeId: currentUser.id, assigneeId: currentUser.id,
}; };

View File

@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({ const props = defineProps({
menuItems: { menuItems: {
@ -37,9 +38,13 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
disableLocalFiltering: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['action']); const emit = defineEmits(['action', 'search']);
const { t } = useI18n(); const { t } = useI18n();
@ -57,6 +62,7 @@ const flattenedMenuItems = computed(() => {
}); });
const filteredMenuItems = computed(() => { const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value; if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item => return flattenedMenuItems.value.filter(item =>
@ -69,7 +75,7 @@ const filteredMenuSections = computed(() => {
return []; return [];
} }
if (!searchQuery.value) { if (props.disableLocalFiltering || !searchQuery.value) {
return props.menuSections; return props.menuSections;
} }
@ -89,6 +95,12 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0); .filter(section => section.items.length > 0);
}); });
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => { const handleAction = item => {
const { action, value, ...rest } = item; const { action, value, ...rest } = item;
emit('action', { action, value, ...rest }); emit('action', { action, value, ...rest });
@ -118,7 +130,7 @@ onMounted(() => {
> >
<div <div
v-if="showSearch" 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"> <div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" /> <span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@ -130,6 +142,7 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER') searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
" "
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12" class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/> />
</div> </div>
</div> </div>
@ -141,10 +154,23 @@ onMounted(() => {
> >
<p <p
v-if="section.title" 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 }} {{ section.title }}
</p> </p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button <button
v-for="(item, itemIndex) in section.items" v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex" :key="item.value || itemIndex"
@ -235,5 +261,6 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE') : t('DROPDOWN_MENU.EMPTY_STATE')
}} }}
</div> </div>
<slot name="footer" />
</div> </div>
</template> </template>

View File

@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
CAMPAIGN_ID: 'campaign_id', CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels', LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language', BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer', REFERER: 'referer',
CREATED_AT: 'created_at', CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at', LAST_ACTIVITY_AT: 'last_activity_at',

View File

@ -7,7 +7,6 @@ import {
buildAttributesFilterTypes, buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES, CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper'; } from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js'; import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/** /**
@ -218,17 +217,6 @@ export function useConversationFilterContext() {
filterOperators: equalityOperators.value, filterOperators: equalityOperators.value,
attributeModel: 'additional', attributeModel: 'additional',
}, },
{
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
inputType: 'searchSelect',
options: countries,
dataType: 'text',
filterOperators: equalityOperators.value,
attributeModel: 'additional',
},
{ {
attributeKey: CONVERSATION_ATTRIBUTES.REFERER, attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
value: CONVERSATION_ATTRIBUTES.REFERER, value: CONVERSATION_ATTRIBUTES.REFERER,

View File

@ -42,7 +42,10 @@ const props = defineProps({
const emit = defineEmits(['retry']); const emit = defineEmits(['retry']);
const allMessages = computed(() => { const allMessages = computed(() => {
return useCamelCase(props.messages, { deep: true }); return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
}); });
const currentChat = useMapGetter('getSelectedChat'); const currentChat = useMapGetter('getSelectedChat');

View File

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

View File

@ -19,6 +19,10 @@ const { attachment } = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
showTranscribedText: {
type: Boolean,
default: true,
},
}); });
defineOptions({ defineOptions({
@ -201,7 +205,7 @@ const downloadAudio = async () => {
</div> </div>
<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" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
> >
{{ attachment.transcribedText }} {{ attachment.transcribedText }}

View File

@ -1,12 +1,11 @@
<script setup> <script setup>
import { h, computed, onMounted } from 'vue'; import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider'; import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd'; import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts'; import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components'; import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
@ -16,7 +15,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue'; import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue'; import SidebarChangelogCard from './SidebarChangelogCard.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue'; import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue'; import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue'; import Logo from 'next/icon/Logo.vue';
@ -55,14 +53,7 @@ const toggleShortcutModalFn = show => {
useSidebarKeyboardShortcuts(toggleShortcutModalFn); useSidebarKeyboardShortcuts(toggleShortcutModalFn);
// We're using localStorage to store the expanded item in the sidebar const expandedItem = ref(null);
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
sessionStorage
);
const setExpandedItem = name => { const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name; expandedItem.value = expandedItem.value === name ? null : name;
@ -495,6 +486,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase', icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'), to: accountScopedRoute('general_settings_index'),
}, },
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{ {
name: 'Settings Agents', name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'), label: t('SIDEBAR.AGENTS'),
@ -682,7 +679,6 @@ const menuItems = computed(() => {
<div <div
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent" class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/> />
<YearInReviewBanner />
<SidebarChangelogCard <SidebarChangelogCard
v-if="isOnChatwootCloud && !isACustomBrandedInstance" v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/> />

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, onMounted, nextTick } from 'vue'; import { computed, onMounted, watch, nextTick } from 'vue';
import { useSidebarContext } from './provider'; import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
@ -128,6 +128,16 @@ onMounted(async () => {
setExpandedItem(props.name); setExpandedItem(props.name);
} }
}); });
watch(
hasActiveChild,
hasNewActiveChild => {
if (hasNewActiveChild && !isExpanded.value) {
setExpandedItem(props.name);
}
},
{ once: true }
);
</script> </script>
<!-- eslint-disable-next-line vue/no-root-v-if --> <!-- eslint-disable-next-line vue/no-root-v-if -->

View File

@ -1,13 +1,11 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { computed } from 'vue';
import Auth from 'dashboard/api/auth'; import Auth from 'dashboard/api/auth';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Avatar from 'next/avatar/Avatar.vue'; import Avatar from 'next/avatar/Avatar.vue';
import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue'; import SidebarProfileMenuStatus from './SidebarProfileMenuStatus.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import YearInReviewModal from 'dashboard/components-next/year-in-review/YearInReviewModal.vue';
import { import {
DropdownContainer, DropdownContainer,
@ -24,7 +22,6 @@ defineOptions({
}); });
const { t } = useI18n(); const { t } = useI18n();
const { uiSettings } = useUISettings();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const currentUserAvailability = useMapGetter('getCurrentUserAvailability'); const currentUserAvailability = useMapGetter('getCurrentUserAvailability');
@ -34,29 +31,6 @@ const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount' 'accounts/isFeatureEnabledonAccount'
); );
const showYearInReviewModal = ref(false);
const bannerClosedKey = computed(() => {
return `yir_closed_${accountId.value}_2025`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const showYearInReviewMenuItem = computed(() => {
return isBannerClosed.value;
});
const openYearInReviewModal = () => {
showYearInReviewModal.value = true;
emit('close');
};
const closeYearInReviewModal = () => {
showYearInReviewModal.value = false;
};
const showChatSupport = computed(() => { const showChatSupport = computed(() => {
return ( return (
isFeatureEnabledonAccount.value( isFeatureEnabledonAccount.value(
@ -68,13 +42,6 @@ const showChatSupport = computed(() => {
const menuItems = computed(() => { const menuItems = computed(() => {
return [ return [
{
show: showYearInReviewMenuItem.value,
showOnCustomBrandedInstance: false,
label: t('SIDEBAR_ITEMS.YEAR_IN_REVIEW'),
icon: 'i-lucide-gift',
click: openYearInReviewModal,
},
{ {
show: showChatSupport.value, show: showChatSupport.value,
showOnCustomBrandedInstance: false, showOnCustomBrandedInstance: false,
@ -190,9 +157,4 @@ const allowedMenuItems = computed(() => {
</template> </template>
</DropdownBody> </DropdownBody>
</DropdownContainer> </DropdownContainer>
<YearInReviewModal
:show="showYearInReviewModal"
@close="closeYearInReviewModal"
/>
</template> </template>

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, ref, onMounted, nextTick } from 'vue'; import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core'; import { useResizeObserver } from '@vueuse/core';
const props = defineProps({ const props = defineProps({
@ -31,20 +31,24 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]); const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => { const updateIndicator = () => {
if (!activeElement.value) return; nextTick(() => {
if (!activeElement.value) return;
indicatorStyle.value = { indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`, left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`, width: `${activeElement.value.offsetWidth}px`,
}; };
});
}; };
useResizeObserver(activeElement, () => { useResizeObserver(activeElement, updateIndicator);
if (enableTransition.value || !activeElement.value) updateIndicator();
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
}); });
onMounted(() => { onMounted(() => {
updateIndicator();
nextTick(() => { nextTick(() => {
enableTransition.value = true; enableTransition.value = true;
}); });
@ -66,7 +70,7 @@ const showDivider = index => {
<template> <template>
<div <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 <div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0" class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"

View File

@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu" v-if="showDropdownMenu"
:menu-items="filteredMenuItems" :menu-items="filteredMenuItems"
:is-searching="isLoading" :is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5" class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction" @action="handleDropdownAction"
/> />
</div> </div>

View File

@ -206,10 +206,14 @@ const emitDateRange = () => {
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]); emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
} }
}; };
const closeDatePicker = () => {
showDatePicker.value = false;
};
</script> </script>
<template> <template>
<div class="relative font-inter"> <div v-on-clickaway="closeDatePicker" class="relative font-inter">
<DatePickerButton <DatePickerButton
:selected-start-date="selectedStartDate" :selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate" :selected-end-date="selectedEndDate"

View File

@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button <button
v-if="text.length > limit" v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top" class="text-n-brand !p-0 !border-0 align-top"
@click="toggleShowMore" @click.stop="toggleShowMore"
> >
{{ buttonLabel }} {{ buttonLabel }}
</button> </button>

View File

@ -55,6 +55,7 @@ import {
getSelectionCoords, getSelectionCoords,
calculateMenuPosition, calculateMenuPosition,
getEffectiveChannelType, getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper'; } from 'dashboard/helper/editorHelper';
import { import {
hasPressedEnterAndNotCmdOrShift, hasPressedEnterAndNotCmdOrShift,
@ -131,8 +132,10 @@ const editorMenuOptions = computed(() => {
const createState = (content, placeholder, plugins = [], methods = {}) => { const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value; const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({ return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(content), doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
plugins: buildEditor({ plugins: buildEditor({
schema, schema,
placeholder, placeholder,
@ -650,11 +653,18 @@ function createEditorView() {
typingIndicator.stop(); typingIndicator.stop();
emit('blur'); emit('blur');
}, },
paste: (_view, event) => { paste: (view, event) => {
if (props.disabled) return; if (props.disabled) return;
const data = event.clipboardData.files; const { files } = event.clipboardData;
if (data.length > 0) { if (!files.length) return;
event.preventDefault(); 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();
} }
}, },
}, },
@ -881,6 +891,7 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important; max-height: none !important;
min-height: 0 !important; min-height: 0 !important;
padding: 0 !important; padding: 0 !important;
display: none !important;
} }
> .ProseMirror { > .ProseMirror {

View File

@ -41,6 +41,9 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) { if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.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'); return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
}; };

View File

@ -8,9 +8,7 @@ import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue'; import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue'; import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue'; import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue'; import ReplyEmailHead from './ReplyEmailHead.vue';
@ -44,6 +42,7 @@ import {
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events'; import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin'; import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import { appendSignature } from 'dashboard/helper/editorHelper'; import { appendSignature } from 'dashboard/helper/editorHelper';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
@ -68,8 +67,6 @@ export default {
WhatsappTemplates, WhatsappTemplates,
WootMessageEditor, WootMessageEditor,
QuotedEmailPreview, QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
}, },
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins], mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: { props: {
@ -113,8 +110,6 @@ export default {
recordingAudioState: '', recordingAudioState: '',
recordingAudioDurationText: '', recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY, replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '', bccEmails: '',
ccEmails: '', ccEmails: '',
toEmails: '', toEmails: '',
@ -146,9 +141,6 @@ export default {
if (!senderId) return {}; if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId); return this.$store.getters['contacts/getContact'](senderId);
}, },
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() { shouldShowReplyToMessage() {
return ( return (
this.inReplyTo?.id && this.inReplyTo?.id &&
@ -410,13 +402,6 @@ export default {
!!this.quotedEmailText !!this.quotedEmailText
); );
}, },
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// Signature preview for non-rich editor (WhatsApp, etc.) // Signature preview for non-rich editor (WhatsApp, etc.)
shouldShowSignaturePreview() { shouldShowSignaturePreview() {
return ( return (
@ -478,21 +463,7 @@ export default {
this.resetRecorderAndClearAttachments(); this.resetRecorderAndClearAttachments();
} }
}, },
message(updatedMessage) { message() {
// Check if the message starts with a slash.
const startsWithSlash = updatedMessage.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
? updatedMessage.substring(1)
: '';
// Autosave the current message draft. // Autosave the current message draft.
this.doAutoSaveDraft(); this.doAutoSaveDraft();
}, },
@ -542,20 +513,14 @@ export default {
methods: { methods: {
handleInsert(article) { handleInsert(article) {
const { url, title } = article; const { url, title } = article;
if (this.isRichEditorEnabled) { // Removing empty lines from the title
// Removing empty lines from the title const lines = title.split('\n');
const lines = title.split('\n'); const nonEmptyLines = lines.filter(line => line.trim() !== '');
const nonEmptyLines = lines.filter(line => line.trim() !== ''); const filteredMarkdown = nonEmptyLines.join(' ');
const filteredMarkdown = nonEmptyLines.join(' '); emitter.emit(
emitter.emit( BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
BUS_EVENTS.INSERT_INTO_RICH_EDITOR, `[${filteredMarkdown}](${url})`
`[${filteredMarkdown}](${url})` );
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK); useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
}, },
@ -632,7 +597,6 @@ export default {
Escape: { Escape: {
action: () => { action: () => {
this.hideEmojiPicker(); this.hideEmojiPicker();
this.hideMentions();
}, },
allowOnFocusedInput: true, allowOnFocusedInput: true,
}, },
@ -687,20 +651,34 @@ export default {
}, },
onPaste(e) { onPaste(e) {
// Don't handle paste if compose new conversation modal is open // Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) { if (this.newConversationModalActive) return;
return;
} // Filter valid files (non-zero size)
const data = e.clipboardData.files; Array.from(e.clipboardData.files)
if (!this.showRichContentEditor && data.length !== 0) { .filter(file => file.size > 0)
this.$refs.messageInput?.$el?.blur(); .filter(file => {
} const isAllowed = isFileTypeAllowedForChannel(file, {
if (!data.length || !data[0]) { channelType: this.channelType || this.inbox?.channel_type,
return; medium: this.inbox?.medium,
} conversationType: this.conversationType,
data.forEach(file => { isInstagramChannel: this.isAnInstagramChannel,
const { name, type, size } = file; isOnPrivateNote: this.isOnPrivateNote,
this.onFileUpload({ name, type, size, file: file }); });
});
if (!isAllowed) {
useAlert(
this.$t('CONVERSATION.FILE_TYPE_NOT_SUPPORTED', {
fileName: file.name,
})
);
}
return isAllowed;
})
.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file });
});
}, },
toggleUserMention(currentMentionState) { toggleUserMention(currentMentionState) {
this.showUserMentions = currentMentionState; this.showUserMentions = currentMentionState;
@ -844,34 +822,16 @@ export default {
}); });
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode; this.replyType = mode;
if (this.showRichContentEditor) { if (this.isRecordingAudio) {
if (this.isRecordingAudio) { this.toggleAudioRecorder();
this.toggleAudioRecorder();
}
return;
} }
this.$nextTick(() => this.$refs.messageInput.focus());
}, },
clearEditorSelection() { clearEditorSelection() {
this.updateEditorSelectionWith = ''; this.updateEditorSelectionWith = '';
}, },
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) { addIntoEditor(content) {
if (this.showRichContentEditor) { this.updateEditorSelectionWith = content;
this.updateEditorSelectionWith = content; this.onFocus();
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
}, },
clearMessage() { clearMessage() {
this.message = ''; this.message = '';
@ -912,9 +872,6 @@ export default {
this.toggleEmojiPicker(); this.toggleEmojiPicker();
} }
}, },
hideMentions() {
this.showMentions = false;
},
onTypingOn() { onTypingOn() {
this.toggleTyping('on'); this.toggleTyping('on');
}, },
@ -1182,13 +1139,6 @@ export default {
:message="inReplyTo" :message="inReplyTo"
@dismiss="resetReplyToMessage" @dismiss="resetReplyToMessage"
/> />
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput <EmojiInput
v-if="showEmojiPicker" v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker" v-on-clickaway="hideEmojiPicker"
@ -1212,53 +1162,7 @@ export default {
@play="recordingAudioState = 'playing'" @play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'" @pause="recordingAudioState = 'paused'"
/> />
<div v-else-if="!showRichContentEditor" class="w-full">
<!-- Signature preview at top for non-rich editor -->
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'top'"
class="signature-preview px-2 py-1 text-slate-500 dark:text-slate-400 text-sm opacity-70 select-none border-b border-slate-100 dark:border-slate-700"
>
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
{{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_TOP') }}
</div>
<div v-dompurify-html="formattedSignature" />
<div
v-if="signatureSeparator === '--'"
class="text-slate-400 dark:text-slate-500 mt-1"
>
{{ signatureSeparator }}
</div>
</div>
<ResizableTextArea
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<!-- Signature preview at bottom for non-rich editor -->
<div
v-if="shouldShowSignaturePreview && signaturePosition === 'bottom'"
class="signature-preview px-2 py-1 mt-2 text-slate-500 dark:text-slate-400 text-sm opacity-70 select-none border-t border-slate-100 dark:border-slate-700"
>
<div class="text-xs text-slate-400 dark:text-slate-500 mb-1">
{{ $t('CONVERSATION.FOOTER.SIGNATURE_LABEL_BOTTOM') }}
</div>
<div
v-if="signatureSeparator === '--'"
class="text-slate-400 dark:text-slate-500 mb-1"
>
{{ signatureSeparator }}
</div>
<div v-dompurify-html="formattedSignature" />
</div>
</div>
<WootMessageEditor <WootMessageEditor
v-else
v-model="message" v-model="message"
:editor-id="editorStateId" :editor-id="editorStateId"
class="input popover-prosemirror-menu" class="input popover-prosemirror-menu"
@ -1384,10 +1288,6 @@ export default {
.reply-box__top { .reply-box__top {
@apply relative py-0 px-4 -mt-px; @apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
} }
.emoji-dialog { .emoji-dialog {
@ -1407,9 +1307,4 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2; @apply ltr:left-1 rtl:right-1 -bottom-2;
} }
} }
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style> </style>

View File

@ -78,14 +78,6 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1, filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional', attributeModel: 'additional',
}, },
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{ {
attributeKey: 'referer', attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK', attributeI18nKey: 'REFERER_LINK',
@ -171,10 +163,6 @@ export const filterAttributeGroups = [
key: 'browser_language', key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE', i18nKey: 'BROWSER_LANGUAGE',
}, },
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{ {
key: 'referer', key: 'referer',
i18nKey: 'REFERER_LINK', i18nKey: 'REFERER_LINK',

View File

@ -1,6 +1,7 @@
<script setup> <script setup>
import { ref, watch, computed, nextTick } from 'vue'; import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList'; import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
items: { items: {
@ -15,6 +16,8 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']); const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null); const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0); const selectedIndex = ref(0);
@ -94,7 +97,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex, 'text-n-slate-12': index === selectedIndex,
}" }"
> >
{{ item.description }} {{ getPlainText(item.description) }}
</p> </p>
<p <p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap" class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"

View File

@ -1,39 +1,31 @@
import { ref } from 'vue'; import { selectTranslation } from '../useTranslations';
import { useTranslations } from '../useTranslations';
describe('useTranslations', () => { describe('selectTranslation', () => {
it('returns false and null when contentAttributes is null', () => { it('returns null when translations is null', () => {
const contentAttributes = ref(null); expect(selectTranslation(null, 'en', 'en')).toBeNull();
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
}); });
it('returns false and null when translations are missing', () => { it('returns null when translations is empty', () => {
const contentAttributes = ref({}); expect(selectTranslation({}, 'en', 'en')).toBeNull();
const { hasTranslations, translationContent } =
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
}); });
it('returns false and null when translations is an empty object', () => { it('returns first translation when no locale matches', () => {
const contentAttributes = ref({ translations: {} }); const translations = { en: 'Hello', es: 'Hola' };
const { hasTranslations, translationContent } = expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
useTranslations(contentAttributes);
expect(hasTranslations.value).toBe(false);
expect(translationContent.value).toBeNull();
}); });
it('returns true and correct translation content when translations exist', () => { it('returns translation matching agent locale', () => {
const contentAttributes = ref({ const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
translations: { en: 'Hello' }, expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
}); });
const { hasTranslations, translationContent } =
useTranslations(contentAttributes); it('falls back to account locale when agent locale not found', () => {
expect(hasTranslations.value).toBe(true); const translations = { en: 'Hello', zh_CN: '你好' };
// Should return the first translation (en: 'Hello') expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
expect(translationContent.value).toBe('Hello'); });
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
}); });
}); });

View File

@ -1,4 +1,25 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/** /**
* Composable to extract translation state/content from contentAttributes. * Composable to extract translation state/content from contentAttributes.
@ -6,6 +27,9 @@ import { computed } from 'vue';
* @returns {Object} { hasTranslations, translationContent } * @returns {Object} { hasTranslations, translationContent }
*/ */
export function useTranslations(contentAttributes) { export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => { const hasTranslations = computed(() => {
if (!contentAttributes.value) return false; if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value; const { translations = {} } = contentAttributes.value;
@ -14,8 +38,11 @@ export function useTranslations(contentAttributes) {
const translationContent = computed(() => { const translationContent = computed(() => {
if (!hasTranslations.value) return null; if (!hasTranslations.value) return null;
const translations = contentAttributes.value.translations; return selectTranslation(
return translations[Object.keys(translations)[0]]; contentAttributes.value.translations,
uiSettings.value?.locale,
currentAccount.value?.locale
);
}); });
return { hasTranslations, translationContent }; return { hasTranslations, translationContent };

View File

@ -140,6 +140,11 @@ export const FORMATTING = {
nodes: [], nodes: [],
menu: ['strong', 'em', 'link', 'undo', 'redo'], menu: ['strong', 'em', 'link', 'undo', 'redo'],
}, },
'Context::Plain': {
marks: [],
nodes: [],
menu: [],
},
}; };
// Editor menu options for Full Editor // Editor menu options for Full Editor
@ -232,8 +237,12 @@ export const MARKDOWN_PATTERNS = [
patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }], patterns: [{ pattern: /`([^`]+)`/g, replacement: '$1' }],
}, },
{ {
type: 'link', // PM: link, eg: [text](url) type: 'link', // PM: link
patterns: [{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }], 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
],
}, },
]; ];

View File

@ -5,4 +5,5 @@ export const LOCAL_STORAGE_KEYS = {
COLOR_SCHEME: 'color_scheme', COLOR_SCHEME: 'color_scheme',
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed', DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo', MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
}; };

View File

@ -43,6 +43,7 @@ export const FEATURE_FLAGS = {
SAML: 'saml', SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply', QUOTED_EMAIL_REPLY: 'quoted_email_reply',
COMPANIES: 'companies', COMPANIES: 'companies',
ADVANCED_SEARCH: 'advanced_search',
}; };
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [

View File

@ -1,4 +1,4 @@
import posthog from 'posthog-js'; import * as amplitude from '@amplitude/analytics-browser';
/** /**
* AnalyticsHelper class to initialize and track user analytics * AnalyticsHelper class to initialize and track user analytics
@ -26,12 +26,10 @@ export class AnalyticsHelper {
return; return;
} }
posthog.init(this.analyticsToken, { amplitude.init(this.analyticsToken, {
api_host: 'https://app.posthog.com', defaultTracking: false,
capture_pageview: false,
persistence: 'localStorage+cookie',
}); });
this.analytics = posthog; this.analytics = amplitude;
} }
/** /**
@ -45,20 +43,26 @@ export class AnalyticsHelper {
} }
this.user = user; this.user = user;
this.analytics.identify(this.user.id.toString(), { this.analytics.setUserId(`user-${this.user.id.toString()}`);
email: this.user.email,
name: this.user.name, const identifyEvent = new amplitude.Identify();
avatar: this.user.avatar_url, 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 { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter( const [currentAccount] = accounts.filter(
account => account.id === accountId account => account.id === accountId
); );
if (currentAccount) { if (currentAccount) {
this.analytics.group('company', currentAccount.id.toString(), { const groupId = `account-${currentAccount.id.toString()}`;
name: currentAccount.name,
}); this.analytics.setGroup('company', groupId);
const groupIdentify = new amplitude.Identify();
groupIdentify.set('name', currentAccount.name);
this.analytics.groupIdentify('company', groupId, groupIdentify);
} }
} }
@ -72,20 +76,21 @@ export class AnalyticsHelper {
if (!this.analytics) { if (!this.analytics) {
return; return;
} }
this.analytics.capture(eventName, properties); this.analytics.track(eventName, properties);
} }
/** /**
* Track the page views * Track the page views
* @function * @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) { if (!this.analytics) {
return; return;
} }
this.analytics.capture('$pageview', params); this.analytics.track('$pageview', { pageName, ...properties });
} }
} }

View File

@ -1,12 +1,15 @@
import helperObject, { AnalyticsHelper } from '../'; import helperObject, { AnalyticsHelper } from '../';
vi.mock('posthog-js', () => ({ vi.mock('@amplitude/analytics-browser', () => ({
default: { init: vi.fn(),
init: vi.fn(), setUserId: vi.fn(),
identify: vi.fn(), identify: vi.fn(),
capture: vi.fn(), setGroup: vi.fn(),
group: vi.fn(), groupIdentify: vi.fn(),
}, track: vi.fn(),
Identify: vi.fn(() => ({
set: vi.fn(),
})),
})); }));
describe('helperObject', () => { describe('helperObject', () => {
@ -22,12 +25,12 @@ describe('AnalyticsHelper', () => {
}); });
describe('init', () => { describe('init', () => {
it('should initialize posthog with the correct token', async () => { it('should initialize amplitude with the correct token', async () => {
await analyticsHelper.init(); await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null); expect(analyticsHelper.analytics).not.toBe(null);
}); });
it('should not initialize posthog if token is not provided', async () => { it('should not initialize amplitude if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper(); analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init(); await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
@ -36,10 +39,15 @@ describe('AnalyticsHelper', () => {
describe('identify', () => { describe('identify', () => {
beforeEach(() => { 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({ analyticsHelper.identify({
id: 123, id: 123,
email: 'test@example.com', email: 'test@example.com',
@ -49,19 +57,18 @@ describe('AnalyticsHelper', () => {
account_id: 1, account_id: 1,
}); });
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', { expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith(
email: 'test@example.com', 'user-123'
name: 'Test User',
avatar: 'avatar_url',
});
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
'company',
'1',
{ name: 'Account 1' }
); );
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({ analyticsHelper.identify({
id: 123, id: 123,
email: 'test@example.com', email: 'test@example.com',
@ -71,10 +78,10 @@ describe('AnalyticsHelper', () => {
account_id: 5, 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.analytics = null;
analyticsHelper.identify({}); analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
@ -83,27 +90,27 @@ describe('AnalyticsHelper', () => {
describe('track', () => { describe('track', () => {
beforeEach(() => { beforeEach(() => {
analyticsHelper.analytics = { capture: vi.fn() }; analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.user = { id: 123 }; 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' }); analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith( expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'Test Event', 'Test Event',
{ prop1: 'value1', prop2: 'value2' } { 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'); analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith( expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'Test Event', '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.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' }); analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
@ -112,24 +119,25 @@ describe('AnalyticsHelper', () => {
describe('page', () => { describe('page', () => {
beforeEach(() => { beforeEach(() => {
analyticsHelper.analytics = { capture: vi.fn() }; analyticsHelper.analytics = { track: vi.fn() };
}); });
it('should call the capture method for pageview with the correct arguments', () => { it('should call the track method for pageview with the correct arguments', () => {
const params = { const pageName = 'home';
name: 'Test page', const properties = {
url: '/test', path: '/test',
name: 'home',
}; };
analyticsHelper.page(params); analyticsHelper.page(pageName, properties);
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith( expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'$pageview', '$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.analytics = null;
analyticsHelper.page(); analyticsHelper.page('home');
expect(analyticsHelper.analytics).toBe(null); expect(analyticsHelper.analytics).toBe(null);
}); });
}); });

View File

@ -34,45 +34,35 @@ export function extractTextFromMarkdown(markdown) {
/** /**
* Strip unsupported markdown formatting based on channel capabilities. * Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
* *
* @param {string} markdown - markdown text to process * @param {string} markdown - markdown text to process
* @param {string} channelType - The channel type to check supported formatting * @param {string} channelType - The channel type to check supported formatting
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
* @returns {string} - The markdown with unsupported formatting removed * @returns {string} - The markdown with unsupported formatting removed
*/ */
export function stripUnsupportedSignatureMarkdown(markdown, channelType) { export function stripUnsupportedMarkdown(
markdown,
channelType,
cleanWhitespace = true
) {
if (!markdown) return ''; if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {}; const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const has = (arr, key) => arr.includes(key); const supported = [...marks, ...nodes];
// Define stripping rules: [condition, pattern, replacement] // Apply patterns from MARKDOWN_PATTERNS for unsupported types
const rules = [ const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
[!has(nodes, 'image'), /!\[.*?\]\(.*?\)/g, ''], if (supported.includes(type)) return text;
[!has(marks, 'link'), /\[([^\]]+)\]\([^)]+\)/g, '$1'], return patterns.reduce(
[!has(nodes, 'codeBlock'), /```[\s\S]*?```/g, ''], (t, { pattern, replacement }) => t.replace(pattern, replacement),
[!has(marks, 'code'), /`([^`]+)`/g, '$1'], text
[!has(marks, 'strong'), /\*\*([^*]+)\*\*/g, '$1'], );
[!has(marks, 'strong'), /__([^_]+)__/g, '$1'], }, markdown);
[!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( if (!cleanWhitespace) return result;
(text, [shouldStrip, pattern, replacement]) =>
shouldStrip ? text.replace(pattern, replacement) : text,
markdown
);
// Clean whitespace for signatures
return result return result
.split('\n') .split('\n')
.map(line => line.trim()) .map(line => line.trim())
@ -182,27 +172,22 @@ export function appendSignature(body, signature, settings = {}) {
/** /**
* Removes the signature from the body, along with the signature delimiter. * 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} body - The body to remove the signature from.
* @param {string} signature - The signature to remove. * @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping. * @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature removed. * @returns {string} - The body with the signature removed.
*/ */
export function removeSignature(body, signature, channelType) { export function removeSignature(body, signature, channelType) {
// Build list of signatures to try: original, channel-stripped, and fully stripped // Build unique list of signature variants to try
const cleanedSignature = cleanSignature(signature);
const channelStripped = channelType const channelStripped = channelType
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType)) ? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
: null; : null;
const fullyStripped = cleanSignature(extractTextFromMarkdown(signature));
// Try signatures in order: original → channel-specific → fully stripped
const signaturesToTry = [ const signaturesToTry = [
cleanedSignature, cleanSignature(signature),
channelStripped, channelStripped,
fullyStripped, cleanSignature(extractTextFromMarkdown(signature)),
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates ].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature // Find the first matching signature
@ -221,17 +206,12 @@ export function removeSignature(body, signature, channelType) {
newBody = newBody.substring(0, signatureIndex).trimEnd(); newBody = newBody.substring(0, signatureIndex).trimEnd();
} }
// let's find the delimiter and remove it // Remove delimiter if it's at the end
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER); if (newBody.endsWith(SIGNATURE_DELIMITER)) {
if (
delimiterIndex !== -1 &&
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
) {
// if the delimiter is at the end, remove it // if the delimiter is at the end, remove it
newBody = newBody.substring(0, delimiterIndex); newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
} }
// return the value
return newBody; return newBody;
} }

View File

@ -1,3 +1,5 @@
import DOMPurify from 'dompurify';
// Quote detection strategies // Quote detection strategies
const QUOTE_INDICATORS = [ const QUOTE_INDICATORS = [
'.gmail_quote_container', '.gmail_quote_container',
@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
static extractQuotes(htmlContent) { static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML // Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent; tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors // Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => { QUOTE_INDICATORS.forEach(selector => {
@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
*/ */
static hasQuotes(htmlContent) { static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = htmlContent; tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes // Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax

View File

@ -20,6 +20,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks', webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing', billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml', saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
}; };
export function getHelpUrlForFeature(featureName) { export function getHelpUrlForFeature(featureName) {

View File

@ -1,4 +1,5 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns'; import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/** /**
* Extracts plain text from HTML content * Extracts plain text from HTML content
@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
return html.replace(/<[^>]*>/g, ' '); return html.replace(/<[^>]*>/g, ' ');
} }
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = html; tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || ''; return tempDiv.textContent || tempDiv.innerText || '';
}; };

View File

@ -5,7 +5,7 @@ import {
replaceSignature, replaceSignature,
cleanSignature, cleanSignature,
extractTextFromMarkdown, extractTextFromMarkdown,
stripUnsupportedSignatureMarkdown, stripUnsupportedMarkdown,
insertAtCursor, insertAtCursor,
findNodeToInsertImage, findNodeToInsertImage,
setURLWithQueryAndSize, setURLWithQueryAndSize,
@ -182,25 +182,19 @@ describe('appendSignature', () => {
}); });
}); });
describe('stripUnsupportedSignatureMarkdown', () => { describe('stripUnsupportedMarkdown', () => {
const richSignature = const richSignature =
'**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)'; '**Bold** _italic_ [link](http://example.com) ![](http://localhost:3000/image.png)';
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => { it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
richSignature,
'Channel::Email'
);
expect(result).toContain('**Bold**'); expect(result).toContain('**Bold**');
expect(result).toContain('_italic_'); expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)'); expect(result).toContain('[link](http://example.com)');
expect(result).toContain('![](http://localhost:3000/image.png)'); expect(result).toContain('![](http://localhost:3000/image.png)');
}); });
it('strips images but keeps bold/italic for Api channel', () => { it('strips images but keeps bold/italic for Api channel', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
richSignature,
'Channel::Api'
);
expect(result).toContain('**Bold**'); expect(result).toContain('**Bold**');
expect(result).toContain('_italic_'); expect(result).toContain('_italic_');
expect(result).toContain('link'); // link text kept expect(result).toContain('link'); // link text kept
@ -208,20 +202,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![]('); // image removed expect(result).not.toContain('![]('); // image removed
}); });
it('strips images but keeps bold/italic/link for Telegram channel', () => { it('strips images but keeps bold/italic/link for Telegram channel', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
richSignature,
'Channel::Telegram'
);
expect(result).toContain('**Bold**'); expect(result).toContain('**Bold**');
expect(result).toContain('_italic_'); expect(result).toContain('_italic_');
expect(result).toContain('[link](http://example.com)'); expect(result).toContain('[link](http://example.com)');
expect(result).not.toContain('![]('); expect(result).not.toContain('![](');
}); });
it('strips all formatting for SMS channel', () => { it('strips all formatting for SMS channel', () => {
const result = stripUnsupportedSignatureMarkdown( const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
richSignature,
'Channel::Sms'
);
expect(result).toContain('Bold'); expect(result).toContain('Bold');
expect(result).toContain('italic'); expect(result).toContain('italic');
expect(result).toContain('link'); expect(result).toContain('link');
@ -231,8 +219,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
expect(result).not.toContain('![]('); expect(result).not.toContain('![](');
}); });
it('returns empty string for empty input', () => { it('returns empty string for empty input', () => {
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe(''); expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe(''); expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
});
describe('with cleanWhitespace parameter', () => {
const textWithWhitespace =
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
it('cleans whitespace when cleanWhitespace=true (default)', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
true
);
expect(result).toBe(
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
);
expect(result).not.toContain('\n\n');
expect(result).not.toContain(' ');
});
it('preserves whitespace when cleanWhitespace=false', () => {
const result = stripUnsupportedMarkdown(
textWithWhitespace,
'Channel::Api',
false
);
expect(result).toContain('\n\n');
expect(result).toContain(' And spaces ');
expect(result).toBe(
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
);
});
it('strips formatting but preserves whitespace for messages', () => {
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
const result = stripUnsupportedMarkdown(
messageWithFormatting,
'Channel::Sms',
false
);
expect(result).toBe('Bold\n\ncode\n\nNormal text');
expect(result).toContain('\n\n');
expect(result).not.toContain('**');
expect(result).not.toContain('`');
});
}); });
}); });
@ -902,6 +934,22 @@ describe('stripUnsupportedFormatting', () => {
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
}); });
it('preserves autolinks when schema supports links', () => {
const content = 'Check out <https://cegrafic.com/catalogo/>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves various URI scheme autolinks', () => {
const content =
'Email <mailto:user@example.com> or call <tel:+1234567890>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves email autolinks', () => {
const content = 'Contact us at <support@chatwoot.com>';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
});
it('preserves lists when schema supports them', () => { it('preserves lists when schema supports them', () => {
const content = '- item 1\n- item 2\n1. first\n2. second'; const content = '- item 1\n- item 2\n1. first\n2. second';
expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content); expect(stripUnsupportedFormatting(content, fullSchema)).toBe(content);
@ -973,6 +1021,38 @@ describe('stripUnsupportedFormatting', () => {
).toBe('Check this link'); ).toBe('Check this link');
}); });
it('converts autolinks to plain URLs when schema does not support links', () => {
const content = 'Visit <https://cegrafic.com/catalogo/> for more info';
const expected = 'Visit https://cegrafic.com/catalogo/ for more info';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles multiple autolinks in content', () => {
const content = 'Check <https://example.com> and <https://test.com>';
const expected = 'Check https://example.com and https://test.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts URI scheme autolinks to plain text', () => {
const content =
'Email <mailto:support@example.com> or call <tel:+1234567890>';
const expected =
'Email mailto:support@example.com or call tel:+1234567890';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('converts email autolinks to plain text', () => {
const content = 'Reach us at <admin@chatwoot.com> for help';
const expected = 'Reach us at admin@chatwoot.com for help';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('handles mixed autolink types', () => {
const content = 'Visit <https://example.com> or email <info@example.com>';
const expected = 'Visit https://example.com or email info@example.com';
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
});
it('strips bullet list markers', () => { it('strips bullet list markers', () => {
expect( expect(
stripUnsupportedFormatting('- item 1\n- item 2', emptySchema) stripUnsupportedFormatting('- item 1\n- item 2', emptySchema)

View File

@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
it('detects quotes for trailing blockquotes even when signatures follow text', () => { it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true); expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
}); });
describe('HTML sanitization', () => {
it('removes onerror handlers from img tags in extractQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).toContain('<p>Hello</p>');
});
it('removes onerror handlers from img tags in hasQuotes', () => {
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
// Should not throw and should safely check for quotes
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
expect(result).toBe(false);
});
it('removes script tags in extractQuotes', () => {
const maliciousHtml =
'<p>Content</p><script>alert("xss")</script><p>More</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('<script');
expect(cleanedHtml).not.toContain('alert');
expect(cleanedHtml).toContain('<p>Content</p>');
expect(cleanedHtml).toContain('<p>More</p>');
});
it('removes onclick handlers in extractQuotes', () => {
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onclick');
expect(cleanedHtml).toContain('Click me');
});
it('removes javascript: URLs in extractQuotes', () => {
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
// eslint-disable-next-line no-script-url
expect(cleanedHtml).not.toContain('javascript:');
expect(cleanedHtml).toContain('Link');
});
it('removes encoded payloads with event handlers in extractQuotes', () => {
const maliciousHtml =
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
expect(cleanedHtml).not.toContain('onerror');
expect(cleanedHtml).not.toContain('eval');
});
});
}); });

View File

@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
expect(result).toContain('Line 1'); expect(result).toContain('Line 1');
expect(result).toContain('Line 2'); expect(result).toContain('Line 2');
}); });
it('sanitizes onerror handlers from img tags', () => {
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello');
});
it('sanitizes script tags', () => {
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Safe');
expect(result).toContain('Content');
expect(result).not.toContain('alert');
});
it('sanitizes onclick handlers', () => {
const html = '<p onclick="alert(1)">Click me</p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Click me');
});
}); });
describe('getEmailSenderName', () => { describe('getEmailSenderName', () => {

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at" "CREATED_AT": "Created at",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -808,6 +808,35 @@
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All", "ALL": "All results",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@ -19,14 +19,50 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus", "PLACEHOLDER_KEYBINDING": "/to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "from", "FROM": "From",
"EMAIL": "email", "EMAIL": "Email",
"EMAIL_SUBJECT": "subject" "EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "الاسم", "NAME": "الاسم",
"DOMAIN": "النطاق", "DOMAIN": "النطاق",
"CREATED_AT": "تم إنشاؤها في" "CREATED_AT": "تم إنشاؤها في",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "النص" "TEXT": "النص"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "جهات الاتصال", "CONTACT": "جهات الاتصال",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -808,6 +808,35 @@
"LABEL": "رسالة", "LABEL": "رسالة",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "اللغة",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "العودة للخلف"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "الكل", "ALL": "All results",
"CONTACTS": "جهات الاتصال", "CONTACTS": "جهات الاتصال",
"CONVERSATIONS": "المحادثات", "CONVERSATIONS": "المحادثات",
"MESSAGES": "الرسائل", "MESSAGES": "الرسائل",
@ -19,14 +19,50 @@
"LOADING_DATA": "جار التحميل", "LOADING_DATA": "جار التحميل",
"EMPTY_STATE": "لم يتم العثور على {item} للطلب '{query}'", "EMPTY_STATE": "لم يتم العثور على {item} للطلب '{query}'",
"EMPTY_STATE_FULL": "لم يتم العثور على نتائج للطلب '{query}'", "EMPTY_STATE_FULL": "لم يتم العثور على نتائج للطلب '{query}'",
"PLACEHOLDER_KEYBINDING": "/ للتركيز", "PLACEHOLDER_KEYBINDING": "/للتركيز",
"INPUT_PLACEHOLDER": "أكتب 3 أحرف أو أكثر للبحث", "INPUT_PLACEHOLDER": "أكتب 3 أحرف أو أكثر للبحث",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "البحث عن طريق معرف المحادثة أو البريد الإلكتروني أو رقم الهاتف أو الرسائل للحصول على نتائج بحث أفضل. ", "EMPTY_STATE_DEFAULT": "البحث عن طريق معرف المحادثة أو البريد الإلكتروني أو رقم الهاتف أو الرسائل للحصول على نتائج بحث أفضل. ",
"BOT_LABEL": "رد آلي", "BOT_LABEL": "رد آلي",
"READ_MORE": "اقرأ المزيد", "READ_MORE": "اقرأ المزيد",
"READ_LESS": "Read less",
"WROTE": "كتب:", "WROTE": "كتب:",
"FROM": "من", "FROM": "من",
"EMAIL": "البريد الإلكتروني", "EMAIL": "البريد الإلكتروني",
"EMAIL_SUBJECT": "الموضوع" "EMAIL_SUBJECT": "الموضوع",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "آخر 7 أيام",
"LAST_30_DAYS": "آخر 30 يوماً",
"LAST_60_DAYS": "آخر 60 يوماً",
"LAST_90_DAYS": "آخر 90 يوماً",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "و",
"APPLY": "تطبيق",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "المرسل",
"IN": "صندوق الوارد",
"AGENTS": "الوكلاء",
"CONTACTS": "جهات الاتصال",
"INBOXES": "قنوات التواصل",
"NO_AGENTS": "لم يتم العثور على وكلاء",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at" "CREATED_AT": "Created at",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -808,6 +808,35 @@
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All", "ALL": "All results",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@ -19,14 +19,50 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus", "PLACEHOLDER_KEYBINDING": "/to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "from", "FROM": "From",
"EMAIL": "email", "EMAIL": "Email",
"EMAIL_SUBJECT": "subject" "EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Име", "NAME": "Име",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Създаден в" "CREATED_AT": "Създаден в",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Контакт", "CONTACT": "Контакт",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -808,6 +808,35 @@
"LABEL": "Съобщение", "LABEL": "Съобщение",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "Всички", "ALL": "All results",
"CONTACTS": "Контакти", "CONTACTS": "Контакти",
"CONVERSATIONS": "Разговори", "CONVERSATIONS": "Разговори",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@ -19,14 +19,50 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus", "PLACEHOLDER_KEYBINDING": "/to focus",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Бот", "BOT_LABEL": "Бот",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "от", "FROM": "From",
"EMAIL": "имейл", "EMAIL": "Email",
"EMAIL_SUBJECT": "тема" "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"
}
} }
} }

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Name", "NAME": "Name",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Created at" "CREATED_AT": "Created at",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -808,6 +808,35 @@
"LABEL": "Message", "LABEL": "Message",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Language",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "All", "ALL": "All results",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations", "CONVERSATIONS": "Conversations",
"MESSAGES": "Messages", "MESSAGES": "Messages",
@ -19,14 +19,50 @@
"LOADING_DATA": "Loading", "LOADING_DATA": "Loading",
"EMPTY_STATE": "No {item} found for query '{query}'", "EMPTY_STATE": "No {item} found for query '{query}'",
"EMPTY_STATE_FULL": "No results found for query '{query}'", "EMPTY_STATE_FULL": "No results found for query '{query}'",
"PLACEHOLDER_KEYBINDING": "/ to focus", "PLACEHOLDER_KEYBINDING": "/to focus",
"INPUT_PLACEHOLDER": "Type 3 or more characters to search", "INPUT_PLACEHOLDER": "Type 3 or more characters to search",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Clear all",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results. ",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Read more", "READ_MORE": "Read more",
"READ_LESS": "Read less",
"WROTE": "wrote:", "WROTE": "wrote:",
"FROM": "from", "FROM": "From",
"EMAIL": "email", "EMAIL": "Email",
"EMAIL_SUBJECT": "subject" "EMAIL_SUBJECT": "Subject",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_60_DAYS": "Last 60 days",
"LAST_90_DAYS": "Last 90 days",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Apply",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Clear filter"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Sender",
"IN": "Inbox",
"AGENTS": "Agents",
"CONTACTS": "Contacts",
"INBOXES": "Inboxes",
"NO_AGENTS": "No agents found",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Nom", "NAME": "Nom",
"DOMAIN": "Domini", "DOMAIN": "Domini",
"CREATED_AT": "Creat per" "CREATED_AT": "Creat per",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Llista" "TEXT": "Llista"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contacte", "CONTACT": "Contacte",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

View File

@ -808,6 +808,35 @@
"LABEL": "Missatge", "LABEL": "Missatge",
"PLACEHOLDER": "Please enter a message to show users with the form" "PLACEHOLDER": "Please enter a message to show users with the form"
}, },
"BUTTON_TEXT": {
"LABEL": "Button text",
"PLACEHOLDER": "Please rate us"
},
"LANGUAGE": {
"LABEL": "Idioma",
"PLACEHOLDER": "Select template language"
},
"MESSAGE_PREVIEW": {
"LABEL": "Message preview",
"TOOLTIP": "This may vary slightly when rendered on WhatsApp's platform."
},
"TEMPLATE_STATUS": {
"APPROVED": "Approved by WhatsApp",
"PENDING": "Pending WhatsApp approval",
"REJECTED": "Meta rejected the template",
"DEFAULT": "Needs WhatsApp approval",
"NOT_FOUND": "The template does not exist in the Meta platform."
},
"TEMPLATE_CREATION": {
"SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval",
"ERROR_MESSAGE": "Failed to create WhatsApp template"
},
"TEMPLATE_UPDATE_DIALOG": {
"TITLE": "Edit survey details",
"DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval",
"CONFIRM": "Create new template",
"CANCEL": "Torna"
},
"SURVEY_RULE": { "SURVEY_RULE": {
"LABEL": "Survey rule", "LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation", "DESCRIPTION_PREFIX": "Send the survey if the conversation",
@ -819,6 +848,7 @@
"SELECT_PLACEHOLDER": "select labels" "SELECT_PLACEHOLDER": "select labels"
}, },
"NOTE": "Note: CSAT surveys are sent only once per conversation", "NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"API": { "API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully", "SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."

View File

@ -1,7 +1,7 @@
{ {
"SEARCH": { "SEARCH": {
"TABS": { "TABS": {
"ALL": "Totes", "ALL": "All results",
"CONTACTS": "Contactes", "CONTACTS": "Contactes",
"CONVERSATIONS": "Converses", "CONVERSATIONS": "Converses",
"MESSAGES": "Missatges", "MESSAGES": "Missatges",
@ -19,14 +19,50 @@
"LOADING_DATA": "Carregant", "LOADING_DATA": "Carregant",
"EMPTY_STATE": "No s'ha trobat cap {item} per a la consulta '{query}'", "EMPTY_STATE": "No s'ha trobat cap {item} per a la consulta '{query}'",
"EMPTY_STATE_FULL": "No s'han trobat resultats per a la consulta '{query}'", "EMPTY_STATE_FULL": "No s'han trobat resultats per a la consulta '{query}'",
"PLACEHOLDER_KEYBINDING": "/ centrar", "PLACEHOLDER_KEYBINDING": "/centrar",
"INPUT_PLACEHOLDER": "Search messages, contacts or conversations", "INPUT_PLACEHOLDER": "Search messages, contacts or conversations",
"RECENT_SEARCHES": "Recent searches",
"CLEAR_ALL": "Esborrar tot",
"MOST_RECENT": "Most recent",
"EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.", "EMPTY_STATE_DEFAULT": "Search by conversation id, email, phone number, messages for better search results.",
"BOT_LABEL": "Bot", "BOT_LABEL": "Bot",
"READ_MORE": "Llegir més", "READ_MORE": "Llegir més",
"READ_LESS": "Read less",
"WROTE": "va escriure:", "WROTE": "va escriure:",
"FROM": "des de", "FROM": "Des de",
"EMAIL": "correu electrònic", "EMAIL": "Correu electrònic",
"EMAIL_SUBJECT": "assumpte" "EMAIL_SUBJECT": "Assumpte",
"PRIVATE": "Private note",
"TRANSCRIPT": "Transcript",
"CREATED_AT": "created {time}",
"UPDATED_AT": "updated {time}",
"SORT_BY": {
"RELEVANCE": "Relevance"
},
"DATE_RANGE": {
"LAST_7_DAYS": "Últims 7 dies",
"LAST_30_DAYS": "Últims 30 dies",
"LAST_60_DAYS": "Últims 60 dies",
"LAST_90_DAYS": "Últims 90 dies",
"CUSTOM_RANGE": "Custom range:",
"CREATED_BETWEEN": "Created between",
"AND": "and",
"APPLY": "Aplica",
"BEFORE_DATE": "Before {date}",
"AFTER_DATE": "After {date}",
"TIME_RANGE": "Filter by time",
"CLEAR_FILTER": "Esborra els filtres"
},
"FILTERS": {
"FILTER_MESSAGE": "Filter messages by:",
"FROM": "Remitent",
"IN": "Safata d'entrada",
"AGENTS": "Agents",
"CONTACTS": "Contactes",
"INBOXES": "Safates d'entrada",
"NO_AGENTS": "No s'han trobat agents",
"NO_CONTACTS": "Start by searching to see results",
"NO_INBOXES": "No inboxes found"
}
} }
} }

View File

@ -6,7 +6,8 @@
"OPTIONS": { "OPTIONS": {
"NAME": "Název", "NAME": "Název",
"DOMAIN": "Domain", "DOMAIN": "Domain",
"CREATED_AT": "Vytvořeno" "CREATED_AT": "Vytvořeno",
"CONTACTS_COUNT": "Contacts count"
} }
}, },
"ORDER": { "ORDER": {

View File

@ -28,6 +28,7 @@
"TYPES": { "TYPES": {
"MEDIA": "Media", "MEDIA": "Media",
"QUICK_REPLY": "Quick Reply", "QUICK_REPLY": "Quick Reply",
"CALL_TO_ACTION": "Call to Action",
"TEXT": "Text" "TEXT": "Text"
} }
}, },

View File

@ -275,6 +275,16 @@
"SIDEBAR": { "SIDEBAR": {
"CONTACT": "Contact", "CONTACT": "Contact",
"COPILOT": "Copilot" "COPILOT": "Copilot"
},
"VOICE_WIDGET": {
"INCOMING_CALL": "Incoming call",
"OUTGOING_CALL": "Outgoing call",
"CALL_IN_PROGRESS": "Call in progress",
"NOT_ANSWERED_YET": "Not answered yet",
"HANDLED_IN_ANOTHER_TAB": "Being handled in another tab",
"REJECT_CALL": "Reject",
"JOIN_CALL": "Join call",
"END_CALL": "End call"
} }
}, },
"EMAIL_TRANSCRIPT": { "EMAIL_TRANSCRIPT": {

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