Compare commits

..
Author SHA1 Message Date
aakashb95 d421da3537 remove 4o and 4o mini 2025-12-17 17:48:42 +05:30
b31b220885 Update lib/llm/config_service.rb
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-17 17:26:08 +05:30
5f689518af Update lib/llm/config_service.rb
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-17 17:25:55 +05:30
c630a609aa Update lib/llm/config_service.rb
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2025-12-17 17:25:46 +05:30
aakashb95 5d1713485c fix rubocop violation 2025-12-17 15:33:12 +05:30
aakashb95 1dde9329dd remove error handling if yaml load fails 2025-12-17 15:19:43 +05:30
aakashb95 bf76fd3769 add spec 2025-12-17 15:19:31 +05:30
aakashb95 a9a113bace add config routes 2025-12-17 15:19:24 +05:30
aakashb95 65e3f8231f captain preference in account settings 2025-12-17 15:18:26 +05:30
aakashb95 f2d3a6ee4f load llm.yml 2025-12-17 14:48:46 +05:30
aakashb95 b67dbf3761 add gpt-5-nano 2025-12-15 17:14:14 +05:30
aakashb95 a49521c9d6 add model config for global captain settings 2025-12-15 17:09:28 +05:30
1062 changed files with 5483 additions and 33999 deletions
+3 -47
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '24.13'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.13'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -148,7 +148,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.13'
node-version: '23.7'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -218,49 +218,6 @@ jobs:
source ~/.rvm/scripts/rvm
bundle install
# Install and configure OpenSearch
- run:
name: Install OpenSearch
command: |
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
sudo mv opensearch-2.11.0 /opt/opensearch
- run:
name: Configure and Start OpenSearch
command: |
# Configure OpenSearch for single-node testing
cat > /opt/opensearch/config/opensearch.yml \<< EOF
cluster.name: chatwoot-test
node.name: node-1
network.host: 0.0.0.0
http.port: 9200
discovery.type: single-node
plugins.security.disabled: true
EOF
# Set ownership and permissions
sudo chown -R $USER:$USER /opt/opensearch
# Start OpenSearch in background
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
- run:
name: Wait for OpenSearch to be ready
command: |
echo "Waiting for OpenSearch to start..."
for i in {1..30}; do
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
echo "OpenSearch is ready!"
exit 0
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "OpenSearch failed to start"
exit 1
# Configure environment and database
- run:
name: Database Setup and Configure Environment Variables
@@ -277,7 +234,6 @@ jobs:
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
# Database setup
- run:
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
-2
View File
@@ -274,5 +274,3 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+3 -3
View File
@@ -28,7 +28,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -43,7 +43,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
run: pnpm i
@@ -94,7 +94,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: Install pnpm dependencies
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: 24
node-version: 23
cache: 'pnpm'
- name: pnpm
+1 -1
View File
@@ -1 +1 @@
24.13.0
23.7.0
-8
View File
@@ -47,13 +47,6 @@
- Avoid writing specs unless explicitly asked
- Remove dead/unreachable/unused code
- Dont write multiple versions or backups for the same logic — pick the best approach and implement it
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
- Example: `feat(auth): add user authentication`
- Don't reference Claude in commit messages
## Project-Specific
@@ -85,4 +78,3 @@ Practical checklist for any change impacting core logic or public APIs
- Keep request/response contracts stable across OSS and Enterprise; update both sets of routes/controllers when introducing new APIs.
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
+15 -20
View File
@@ -140,27 +140,24 @@ GEM
actionmailbox (>= 7.1.0)
aws-sdk-s3 (~> 1, >= 1.123.0)
aws-sdk-sns (~> 1, >= 1.61.0)
aws-eventstream (1.4.0)
aws-partitions (1.1198.0)
aws-sdk-core (3.240.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
aws-eventstream (1.2.0)
aws-partitions (1.760.0)
aws-sdk-core (3.188.0)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.118.0)
aws-sdk-core (~> 3, >= 3.239.1)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.208.0)
aws-sdk-core (~> 3, >= 3.234.0)
aws-sdk-kms (1.64.0)
aws-sdk-core (~> 3, >= 3.165.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.126.0)
aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (~> 1.4)
aws-sdk-sns (1.70.0)
aws-sdk-core (~> 3, >= 3.188.0)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.12.1)
aws-sigv4 (1.5.2)
aws-eventstream (~> 1, >= 1.0.2)
barnes (0.0.9)
multi_json (~> 1)
@@ -441,8 +438,7 @@ GEM
http-cookie (1.0.5)
domain_name (~> 0.5)
http-form_data (2.3.0)
httparty (0.24.0)
csv
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
@@ -556,8 +552,7 @@ GEM
ruby2_keywords
msgpack (1.8.0)
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multi_xml (0.6.0)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
+2 -1
View File
@@ -8,6 +8,7 @@ ___
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
<p>
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
*Chatwoot* &copy; 2017-2025, Chatwoot Inc - Released under the MIT License.
+1 -1
View File
@@ -1 +1 @@
4.10.0
4.8.0
+1 -1
View File
@@ -1 +1 @@
3.5.0
3.4.3
@@ -1,38 +0,0 @@
class V2::Reports::ChannelSummaryBuilder
include DateRangeHelper
pattr_initialize [:account!, :params!]
def build
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
end
private
def conversations_by_channel_and_status
account.conversations
.joins(:inbox)
.where(created_at: range)
.group('inboxes.channel_type', 'conversations.status')
.count
.each_with_object({}) do |((channel_type, status), count), grouped|
grouped[channel_type] ||= {}
grouped[channel_type][status] = count
end
end
def build_channel_stats(status_counts)
open_count = status_counts['open'] || 0
resolved_count = status_counts['resolved'] || 0
pending_count = status_counts['pending'] || 0
snoozed_count = status_counts['snoozed'] || 0
{
open: open_count,
resolved: resolved_count,
pending: pending_count,
snoozed: snoozed_count,
total: open_count + resolved_count + pending_count + snoozed_count
}
end
end
-74
View File
@@ -1,74 +0,0 @@
class YearInReviewBuilder
attr_reader :account, :user_id, :year
def initialize(account:, user_id:, year:)
@account = account
@user_id = user_id
@year = year
end
def build
{
year: year,
total_conversations: total_conversations_count,
busiest_day: busiest_day_data,
support_personality: support_personality_data
}
end
private
def year_range
@year_range ||= begin
start_time = Time.zone.local(year, 1, 1).beginning_of_day
end_time = Time.zone.local(year, 12, 31).end_of_day
start_time..end_time
end
end
def total_conversations_count
account.conversations
.where(assignee_id: user_id, created_at: year_range)
.count
end
def busiest_day_data
daily_counts = account.conversations
.where(assignee_id: user_id, created_at: year_range)
.group_by_day(:created_at, range: year_range, time_zone: Time.zone)
.count
return nil if daily_counts.empty?
busiest_date, count = daily_counts.max_by { |_date, cnt| cnt }
return nil if count.zero?
{
date: busiest_date.strftime('%b %d'),
count: count
}
end
def support_personality_data
response_time = average_response_time
return { avg_response_time_seconds: 0 } if response_time.nil?
{
avg_response_time_seconds: response_time.to_i
}
end
def average_response_time
avg_time = account.reporting_events
.where(
name: 'first_response',
user_id: user_id,
created_at: year_range
)
.average(:value)
avg_time&.to_f
end
end
@@ -1,6 +1,4 @@
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
@@ -11,32 +9,25 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
def show; end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = actions
@automation_rule.actions = params[:actions]
@automation_rule.conditions = params[:conditions]
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
process_attachments
@automation_rule
end
def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@automation_rule.assign_attributes(automation_rules_permit)
@automation_rule.actions = actions if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
blobs.each { |blob| @automation_rule.files.attach(blob) }
automation_rule_update
process_attachments
rescue StandardError => e
Rails.logger.error e
render_could_not_create_error(@automation_rule.errors.messages)
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end
@@ -52,11 +43,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
def process_attachments
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private
def automation_rule_update
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.conditions = params[:conditions] if params[:conditions]
@automation_rule.save!
end
def automation_rules_permit
params.permit(
:name, :description, :event_name, :active,
:name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
@@ -0,0 +1,11 @@
class Api::V1::Accounts::Captain::ConfigController < Api::V1::Accounts::BaseController
before_action :current_account
def show
render json: {
providers: Llm::ConfigService.providers,
models: Llm::ConfigService.models,
features: Llm::ConfigService.all_features_config
}
end
end
@@ -1,76 +0,0 @@
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :authorize_account_update, only: [:update]
def show
render json: preferences_payload
end
def update
params_to_update = captain_params
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
@current_account.save!
render json: preferences_payload
end
private
def preferences_payload
{
providers: Llm::Models.providers,
models: Llm::Models.models,
features: features_with_account_preferences
}
end
def authorize_account_update
authorize @current_account, :update?
end
def captain_params
permitted = {}
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
permitted
end
def merged_captain_models
existing_models = @current_account.captain_models || {}
existing_models.merge(permitted_captain_models)
end
def merged_captain_features
existing_features = @current_account.captain_features || {}
existing_features.merge(permitted_captain_features)
end
def permitted_captain_models
params.require(:captain_models).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def permitted_captain_features
params.require(:captain_features).permit(
:editor, :assistant, :copilot, :label_suggestion,
:audio_transcription, :help_center_search
).to_h.stringify_keys
end
def features_with_account_preferences
preferences = Current.account.captain_preferences
account_features = preferences[:features] || {}
account_models = preferences[:models] || {}
Llm::Models.feature_keys.index_with do |feature_key|
config = Llm::Models.feature_config(feature_key)
config.merge(
enabled: account_features[feature_key] == true,
selected: account_models[feature_key] || config[:default]
)
end
end
end
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
def permitted_params
params.require(:twilio_channel).permit(
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
)
end
end
@@ -50,5 +50,3 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -1,27 +1,38 @@
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 :validate_whatsapp_channel
def show
service = CsatTemplateManagementService.new(@inbox)
result = service.template_status
template = @inbox.csat_config&.dig('template')
return render json: { template_exists: false } unless template
if result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render json: result
end
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
status_result = @inbox.channel.provider_service.get_template_status(template_name)
render_template_status_response(status_result, template_name)
rescue StandardError => e
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
render json: { error: e.message }, status: :internal_server_error
end
def create
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
service = CsatTemplateManagementService.new(@inbox)
result = service.create_template(template_params)
# Delete existing template even though we are using a new one.
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
delete_existing_template_if_needed
result = create_template_via_provider(template_params)
render_template_creation_result(result)
rescue ActionController::ParameterMissing
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
private
@@ -32,9 +43,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
end
def validate_whatsapp_channel
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
return if @inbox.whatsapp?
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
status: :bad_request
end
@@ -46,36 +57,35 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
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)
if result[:success]
render_successful_template_creation(result)
elsif result[:service_error]
render json: { error: result[:service_error] }, status: :internal_server_error
else
render_failed_template_creation(result)
end
end
def render_successful_template_creation(result)
if @inbox.twilio_whatsapp?
render json: {
template: {
friendly_name: result[:friendly_name],
content_sid: result[:content_sid],
status: result[:status] || 'pending',
language: result[:language] || 'en'
}
}, status: :created
else
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || 'en'
}
}, status: :created
end
render json: {
template: {
name: result[:template_name],
template_id: result[:template_id],
status: 'PENDING',
language: result[:language] || DEFAULT_LANGUAGE
}
}, status: :created
end
def render_failed_template_creation(result)
@@ -88,6 +98,45 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
}, status: :unprocessable_entity
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)
return { user_message: nil, technical_details: nil } if response_body.blank?
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
{ csat_config: [:display_type, :message, :button_text, :language,
{ survey_rules: [:operator, { values: [] }],
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
template: [:name, :template_id, :created_at, :language] }] }]
end
def permitted_params(channel_attributes = [])
@@ -1,6 +1,4 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
include AttachmentConcern
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
@@ -13,32 +11,26 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
end
def create
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
return render_could_not_create_error(error) if error
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@macro.actions = actions
@macro.actions = params[:actions]
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
blobs.each { |blob| @macro.files.attach(blob) }
process_attachments
@macro
end
def update
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
return render_could_not_create_error(error) if error
ActiveRecord::Base.transaction do
@macro.assign_attributes(macros_with_user)
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
@macro.actions = actions if params[:actions]
process_attachments
@macro.save!
blobs.each { |blob| @macro.files.attach(blob) }
rescue StandardError => e
Rails.logger.error e
render_could_not_create_error(@macro.errors.messages)
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
@@ -55,9 +47,20 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :visibility,
:name, :account_id, :visibility,
actions: [:action_name, { action_params: [] }]
)
end
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id)
blob = ActiveStorage::Blob.find_by(id: blob_id)
@portal.logo.attach(blob)
end
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
def portal_params
params.require(:portal).permit(
:id, :color, :custom_domain, :header_text, :homepage_link,
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -28,7 +28,5 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
search_type: search_type,
params: params
).perform
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
end
@@ -1,15 +0,0 @@
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include Tiktok::IntegrationHelper
def create
redirect_url = Tiktok::AuthClient.authorize_url(
state: generate_tiktok_token(Current.account.id)
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
end
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
def render_error(message, status)
@@ -92,8 +92,10 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
permitted = params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
permitted[:captain_models] = params[:captain_models].to_unsafe_h if params[:captain_models].present?
permitted[:captain_features] = params[:captain_features].to_unsafe_h if params[:captain_features].present?
permitted
end
def check_signup_enabled
@@ -38,11 +38,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversations_summary
@report_data = generate_conversations_report
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -18,12 +18,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::LabelSummaryBuilder)
end
def channel
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
render_report_with(V2::Reports::ChannelSummaryBuilder)
end
private
def check_authorization
@@ -46,12 +40,4 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
def permitted_params
params.permit(:since, :until, :business_hours)
end
def date_range_too_long?
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
since_time = Time.zone.at(permitted_params[:since].to_i)
until_time = Time.zone.at(permitted_params[:until].to_i)
(until_time - since_time) > 6.months
end
end
@@ -1,26 +0,0 @@
class Api::V2::Accounts::YearInReviewsController < Api::V1::Accounts::BaseController
def show
year = params[:year] || 2025
cache_key = "year_in_review_#{Current.account.id}_#{year}"
cached_data = Current.user.ui_settings&.dig(cache_key)
if cached_data.present?
render json: cached_data
else
builder = YearInReviewBuilder.new(
account: Current.account,
user_id: Current.user.id,
year: year
)
data = builder.build
ui_settings = Current.user.ui_settings || {}
ui_settings[cache_key] = data
Current.user.update(ui_settings: ui_settings)
render json: data
end
end
end
@@ -1,35 +0,0 @@
module AttachmentConcern
extend ActiveSupport::Concern
def validate_and_prepare_attachments(actions, record = nil)
blobs = []
return [blobs, actions, nil] if actions.blank?
sanitized = actions.map do |action|
next action unless action[:action_name] == 'send_attachment'
result = process_attachment_action(action, record, blobs)
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
result
end
[blobs, sanitized, nil]
end
private
def process_attachment_action(action, record, blobs)
blob_id = action[:action_params].first
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
return action if blob_already_attached?(record, blob_id)
nil
end
def blob_already_attached?(record, blob_id)
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
end
end
+1 -2
View File
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
CHATWOOT_INBOX_TOKEN
API_CHANNEL_NAME
API_CHANNEL_THUMBNAIL
CLOUD_ANALYTICS_TOKEN
ANALYTICS_TOKEN
DIRECT_UPLOADS_ENABLED
MAXIMUM_FILE_UPLOAD_SIZE
HCAPTCHA_SITE_KEY
@@ -73,7 +73,6 @@ class DashboardController < ActionController::Base
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
@@ -46,7 +46,6 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
@@ -1,144 +0,0 @@
class Tiktok::CallbacksController < ApplicationController
include Tiktok::IntegrationHelper
def show
return handle_authorization_error if params[:error].present?
return handle_ungranted_scopes_error unless all_scopes_granted?
process_successful_authorization
rescue StandardError => e
handle_error(e)
end
private
def all_scopes_granted?
granted_scopes = short_term_access_token[:scope].to_s.split(',')
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
end
def process_successful_authorization
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
else
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
end
end
def handle_error(error)
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
ChatwootExceptionTracker.new(error).capture_exception
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
end
# Handles the case when a user denies permissions or cancels the authorization flow
def handle_authorization_error
redirect_to_error_page(
error_type: params[:error] || 'access_denied',
code: params[:error_code],
error_message: params[:error_description] || 'User cancelled the Authorization'
)
end
# Handles the case when a user partially accepted the required scopes
def handle_ungranted_scopes_error
redirect_to_error_page(
error_type: 'ungranted_scopes',
code: 400,
error_message: 'User did not grant all the required scopes'
)
end
# Centralized method to redirect to error page with appropriate parameters
# This ensures consistent error handling across different error scenarios
# Frontend will handle the error page based on the error_type
def redirect_to_error_page(error_type:, code:, error_message:)
redirect_to app_new_tiktok_inbox_url(
account_id: account_id,
error_type: error_type,
code: code,
error_message: error_message
)
end
def find_or_create_inbox
business_details = tiktok_client.business_account_details
channel_tiktok = find_channel
channel_exists = channel_tiktok.present?
if channel_tiktok
update_channel(channel_tiktok, business_details)
else
channel_tiktok = create_channel_with_inbox(business_details)
end
# reauthorized will also update cache keys for the associated inbox
channel_tiktok.reauthorized!
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
[channel_tiktok.inbox, channel_exists]
end
def create_channel_with_inbox(business_details)
ActiveRecord::Base.transaction do
channel_tiktok = Channel::Tiktok.create!(
account: account,
business_id: short_term_access_token[:business_id],
access_token: short_term_access_token[:access_token],
refresh_token: short_term_access_token[:refresh_token],
expires_at: short_term_access_token[:expires_at],
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
)
account.inboxes.create!(
account: account,
channel: channel_tiktok,
name: business_details[:display_name].presence || business_details[:username]
)
channel_tiktok
end
end
def find_channel
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
end
def update_channel(channel_tiktok, business_details)
channel_tiktok.update!(
access_token: short_term_access_token[:access_token],
refresh_token: short_term_access_token[:refresh_token],
expires_at: short_term_access_token[:expires_at],
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
)
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
end
def set_avatar(inbox, avatar_url)
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
end
def account_id
@account_id ||= verify_tiktok_token(params[:state])
end
def account
@account ||= Account.find(account_id)
end
def short_term_access_token
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
end
def tiktok_client
@tiktok_client ||= Tiktok::Client.new(
business_id: short_term_access_token[:business_id],
access_token: short_term_access_token[:access_token]
)
end
end
@@ -1,53 +0,0 @@
class Webhooks::TiktokController < ActionController::API
before_action :verify_signature!
def events
event = JSON.parse(request_payload)
if echo_event?
# Add delay to prevent race condition where echo arrives before send message API completes
# This avoids duplicate messages when echo comes early during API processing
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
else
::Webhooks::TiktokEventsJob.perform_later(event)
end
head :ok
end
private
def request_payload
@request_payload ||= request.body.read
end
def verify_signature!
signature_header = request.headers['Tiktok-Signature']
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
received_timestamp, received_signature = extract_signature_parts(signature_header)
return head :unauthorized unless client_secret && received_timestamp && received_signature
signature_payload = "#{received_timestamp}.#{request_payload}"
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
# Check timestamp delay (acceptable delay: 5 seconds)
current_timestamp = Time.current.to_i
delay = current_timestamp - received_timestamp
return head :unauthorized if delay > 5
end
def extract_signature_parts(signature_header)
return [nil, nil] if signature_header.blank?
keys = signature_header.split(',')
signature_parts = keys.map { |part| part.split('=') }.to_h
[signature_parts['t']&.to_i, signature_parts['s']]
end
def echo_event?
params[:event] == 'im_send_msg'
end
end
@@ -46,13 +46,6 @@ module Api::V2::Accounts::ReportsHelper
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
def build_params(base_params)
@@ -78,16 +71,4 @@ module Api::V2::Accounts::ReportsHelper
report[:resolved_conversations_count]
]
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
-6
View File
@@ -78,12 +78,6 @@ instagram:
enabled: true
icon: 'icon-instagram'
config_key: 'instagram'
tiktok:
name: 'TikTok'
description: 'Stay connected with your customers on TikTok'
enabled: true
icon: 'icon-tiktok'
config_key: 'tiktok'
whatsapp:
name: 'WhatsApp'
description: 'Manage your WhatsApp business interactions from Chatwoot.'
-47
View File
@@ -1,47 +0,0 @@
module Tiktok::IntegrationHelper
# Generates a signed JWT token for Tiktok integration
#
# @param account_id [Integer] The account ID to encode in the token
# @return [String, nil] The encoded JWT token or nil if client secret is missing
def generate_tiktok_token(account_id)
return if client_secret.blank?
JWT.encode(token_payload(account_id), client_secret, 'HS256')
rescue StandardError => e
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
nil
end
# Verifies and decodes a Tiktok JWT token
#
# @param token [String] The JWT token to verify
# @return [Integer, nil] The account ID from the token or nil if invalid
def verify_tiktok_token(token)
return if token.blank? || client_secret.blank?
decode_token(token, client_secret)
end
private
def client_secret
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
end
def token_payload(account_id)
{
sub: account_id,
iat: Time.current.to_i
}
end
def decode_token(token, secret)
JWT.decode(token, secret, true, {
algorithm: 'HS256',
verify_expiration: true
}).first['sub']
rescue StandardError => e
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
nil
end
end
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainPreferences extends ApiClient {
constructor() {
super('captain/preferences', { accountScoped: true });
}
get() {
return axios.get(this.url);
}
updatePreferences(data) {
return axios.put(this.url, data);
}
}
export default new CaptainPreferences();
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class TiktokChannel extends ApiClient {
constructor() {
super('tiktok', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new TiktokChannel();
@@ -1,95 +0,0 @@
import { Device } from '@twilio/voice-sdk';
import VoiceAPI from './voiceAPIClient';
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
class TwilioVoiceClient extends EventTarget {
constructor() {
super();
this.device = null;
this.activeConnection = null;
this.initialized = false;
this.inboxId = null;
}
async initializeDevice(inboxId) {
this.destroyDevice();
const response = await VoiceAPI.getToken(inboxId);
const { token, account_id } = response || {};
if (!token) throw new Error('Invalid token');
this.device = new Device(token, {
allowIncomingWhileBusy: true,
disableAudioContextSounds: true,
appParams: { account_id },
});
this.device.removeAllListeners();
this.device.on('connect', conn => {
this.activeConnection = conn;
conn.on('disconnect', this.onDisconnect);
});
this.device.on('disconnect', this.onDisconnect);
this.device.on('tokenWillExpire', async () => {
const r = await VoiceAPI.getToken(this.inboxId);
if (r?.token) this.device.updateToken(r.token);
});
this.initialized = true;
this.inboxId = inboxId;
return this.device;
}
get hasActiveConnection() {
return !!this.activeConnection;
}
endClientCall() {
if (this.activeConnection) {
this.activeConnection.disconnect();
}
this.activeConnection = null;
if (this.device) {
this.device.disconnectAll();
}
}
destroyDevice() {
if (this.device) {
this.device.destroy();
}
this.activeConnection = null;
this.device = null;
this.initialized = false;
this.inboxId = null;
}
async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection;
const params = {
To: to,
is_agent: 'true',
conversation_id: conversationId,
};
const connection = await this.device.connect({ params });
this.activeConnection = connection;
connection.on('disconnect', this.onDisconnect);
return connection;
}
onDisconnect = () => {
this.activeConnection = null;
this.dispatchEvent(createCallDisconnectedEvent());
};
}
export default new TwilioVoiceClient();
@@ -1,40 +0,0 @@
/* global axios */
import ApiClient from '../../ApiClient';
import ContactsAPI from '../../contacts';
class VoiceAPI extends ApiClient {
constructor() {
super('voice', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
initiateCall(contactId, inboxId) {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
}
leaveConference(inboxId, conversationId) {
return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId },
})
.then(r => r.data);
}
joinConference({ conversationId, inboxId, callSid }) {
return axios
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
conversation_id: conversationId,
call_sid: callSid,
})
.then(r => r.data);
}
getToken(inboxId) {
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
return axios
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
.then(r => r.data);
}
}
export default new VoiceAPI();
-10
View File
@@ -32,16 +32,6 @@ class Inboxes extends CacheEnabledApiClient {
syncTemplates(inboxId) {
return axios.post(`${this.url}/${inboxId}/sync_templates`);
}
createCSATTemplate(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template`, {
template,
});
}
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
}
export default new Inboxes();
-6
View File
@@ -61,12 +61,6 @@ 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 } = {}) {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
+4 -14
View File
@@ -14,48 +14,38 @@ class SearchAPI extends ApiClient {
});
}
contacts({ q, page = 1, since, until }) {
contacts({ q, page = 1 }) {
return axios.get(`${this.url}/contacts`, {
params: {
q,
page: page,
since,
until,
},
});
}
conversations({ q, page = 1, since, until }) {
conversations({ q, page = 1 }) {
return axios.get(`${this.url}/conversations`, {
params: {
q,
page: page,
since,
until,
},
});
}
messages({ q, page = 1, since, until, from, inboxId }) {
messages({ q, page = 1 }) {
return axios.get(`${this.url}/messages`, {
params: {
q,
page: page,
since,
until,
from,
inbox_id: inboxId,
},
});
}
articles({ q, page = 1, since, until }) {
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
since,
until,
},
});
}
@@ -1,134 +0,0 @@
import searchAPI from '../search';
import ApiClient from '../ApiClient';
describe('#SearchAPI', () => {
it('creates correct instance', () => {
expect(searchAPI).toBeInstanceOf(ApiClient);
expect(searchAPI).toHaveProperty('get');
expect(searchAPI).toHaveProperty('contacts');
expect(searchAPI).toHaveProperty('conversations');
expect(searchAPI).toHaveProperty('messages');
expect(searchAPI).toHaveProperty('articles');
});
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
get: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
vi.clearAllMocks();
});
it('#get', () => {
searchAPI.get({ q: 'test query' });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
params: { q: 'test query' },
});
});
it('#contacts', () => {
searchAPI.contacts({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#contacts with date filters', () => {
searchAPI.contacts({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
it('#conversations', () => {
searchAPI.conversations({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: undefined, until: undefined },
}
);
});
it('#conversations with date filters', () => {
searchAPI.conversations({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/search/conversations',
{
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
}
);
});
it('#messages', () => {
searchAPI.messages({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: undefined,
until: undefined,
from: undefined,
inbox_id: undefined,
},
});
});
it('#messages with all filters', () => {
searchAPI.messages({
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inboxId: 10,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
params: {
q: 'test',
page: 1,
since: 1700000000,
until: 1732000000,
from: 'contact:42',
inbox_id: 10,
},
});
});
it('#articles', () => {
searchAPI.articles({ q: 'test', page: 1 });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 1, since: undefined, until: undefined },
});
});
it('#articles with date filters', () => {
searchAPI.articles({
q: 'test',
page: 2,
since: 1700000000,
until: 1732000000,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
});
});
});
});
@@ -1,35 +0,0 @@
import ApiClient from '../ApiClient';
import tiktokClient from '../channel/tiktokClient';
describe('#TiktokClient', () => {
it('creates correct instance', () => {
expect(tiktokClient).toBeInstanceOf(ApiClient);
expect(tiktokClient).toHaveProperty('generateAuthorization');
});
describe('#generateAuthorization', () => {
const originalAxios = window.axios;
const originalPathname = window.location.pathname;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
window.history.pushState({}, '', '/app/accounts/1/settings');
});
afterEach(() => {
window.axios = originalAxios;
window.history.pushState({}, '', originalPathname);
});
it('posts to the authorization endpoint', () => {
tiktokClient.generateAuthorization({ state: 'test-state' });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/tiktok/authorization',
{ state: 'test-state' }
);
});
});
});
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class YearInReviewAPI extends ApiClient {
constructor() {
super('year_in_review', { accountScoped: true, apiVersion: 'v2' });
}
get(year) {
return axios.get(`${this.url}`, {
params: { year },
});
}
}
export default new YearInReviewAPI();
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -35,10 +35,6 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at',
},
{
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count',
},
];
const orderingMenus = [
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
@@ -66,7 +65,6 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
tiktok: '',
linkedin: '',
twitter: '',
},
@@ -40,12 +40,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleDialogConfirm"
>
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
<ContactsForm
ref="contactsFormRef"
is-new-contact
@@ -6,7 +6,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { useAlert } from 'dashboard/composables';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import { useCallsStore } from 'dashboard/stores/calls';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
@@ -68,17 +67,6 @@ const startCall = async inboxId => {
contactId: props.contactId,
inboxId,
});
const { call_sid: callSid, conversation_id: conversationId } = response;
// Add call to store immediately so widget shows
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
inboxId,
callDirection: 'outbound',
});
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
navigateToConversation(response?.conversation_id);
} catch (error) {
@@ -1,88 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
badges: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
type: {
type: String,
default: 'resolution',
validator: value => ['pre-chat', 'resolution'].includes(value),
},
});
const { t } = useI18n();
const attributeConfig = {
'pre-chat': {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
},
};
const config = computed(
() => attributeConfig[props.type] || attributeConfig.resolution
);
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -5,7 +5,6 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
@@ -97,7 +96,6 @@ watch(
]"
>
<WootEditor
:editor-id="editorKey"
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
@@ -154,13 +152,6 @@ watch(
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -9,8 +9,6 @@ import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
searchContacts,
createNewContact,
@@ -228,8 +226,6 @@ const keyboardEvents = {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
emit('close');
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
}
},
},
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { useEventListener } from '@vueuse/core';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
@@ -44,12 +43,6 @@ const emit = defineEmits([
const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
@@ -170,24 +163,6 @@ const keyboardEvents = {
},
};
useKeyboardEvents(keyboardEvents);
const onPaste = e => {
if (!props.isEmailOrWebWidgetInbox) return;
const files = e.clipboardData?.files;
if (!files?.length) return;
// Filter valid files (non-zero size)
Array.from(files)
.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);
</script>
<template>
@@ -7,7 +7,6 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -48,8 +47,6 @@ const emit = defineEmits([
'createConversation',
]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@@ -201,22 +198,10 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true;
};
const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
// Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
@@ -236,9 +221,7 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING);
state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -341,68 +324,67 @@ const shouldShowMessageEditor = computed(() => {
<template>
<div
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)]"
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"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
</div>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<ActionButtons
:attached-files="state.attachedFiles"
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
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"
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"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -6,6 +6,7 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
@@ -23,14 +24,14 @@ const modelValue = defineModel({
</script>
<template>
<div class="flex-1 h-full">
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<Editor
:key="editorKey"
v-model="modelValue"
:editor-key="editorKey"
: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-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
@@ -146,7 +146,7 @@ const STYLE_CONFIG = {
solid:
'bg-n-teal-9 text-white hover:enabled:bg-n-teal-10 focus-visible:bg-n-teal-10 outline-transparent',
faded:
'bg-n-teal-9/10 text-n-teal-11 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
'bg-n-teal-9/10 text-n-slate-12 hover:enabled:bg-n-teal-9/20 focus-visible:bg-n-teal-9/20 outline-transparent',
outline:
'text-n-teal-11 hover:enabled:bg-n-teal-9/10 focus-visible:bg-n-teal-9/10 outline-n-teal-9',
link: 'text-n-teal-9 hover:enabled:underline focus-visible:underline outline-transparent',
@@ -1,6 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useBranding } from 'shared/composables/useBranding';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -10,8 +9,6 @@ import { documentsList } from 'dashboard/components-next/captain/pageComponents/
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
};
@@ -38,7 +35,7 @@ const onClick = () => {
v-for="(document, index) in documentsList.slice(0, 5)"
:id="document.id"
:key="`document-${index}`"
:name="replaceInstallationName(document.name)"
:name="document.name"
:assistant="document.assistant"
:external-link="document.external_link"
:created-at="document.created_at"
@@ -1,6 +1,5 @@
<script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import { useBranding } from 'shared/composables/useBranding';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
@@ -27,7 +26,6 @@ const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const onClick = () => {
emit('click');
@@ -65,8 +63,8 @@ const onClearFilters = () => {
v-for="(response, index) in responsesList.slice(0, 5)"
:id="response.id"
:key="`response-${index}`"
:question="replaceInstallationName(response.question)"
:answer="replaceInstallationName(response.answer)"
:question="response.question"
:answer="response.answer"
:status="response.status"
:assistant="response.assistant"
:created-at="response.created_at"
@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
menuItems: {
@@ -38,13 +37,9 @@ const props = defineProps({
type: String,
default: '',
},
disableLocalFiltering: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action']);
const { t } = useI18n();
@@ -62,7 +57,6 @@ const flattenedMenuItems = computed(() => {
});
const filteredMenuItems = computed(() => {
if (props.disableLocalFiltering) return props.menuItems;
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
@@ -75,7 +69,7 @@ const filteredMenuSections = computed(() => {
return [];
}
if (props.disableLocalFiltering || !searchQuery.value) {
if (!searchQuery.value) {
return props.menuSections;
}
@@ -95,12 +89,6 @@ const filteredMenuSections = computed(() => {
.filter(section => section.items.length > 0);
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
@@ -130,7 +118,7 @@ onMounted(() => {
>
<div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
@@ -142,7 +130,6 @@ onMounted(() => {
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div>
@@ -154,23 +141,10 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
@@ -261,6 +235,5 @@ onMounted(() => {
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
</div>
</template>
@@ -11,6 +11,7 @@ export const CONVERSATION_ATTRIBUTES = {
CAMPAIGN_ID: 'campaign_id',
LABELS: 'labels',
BROWSER_LANGUAGE: 'browser_language',
COUNTRY_CODE: 'country_code',
REFERER: 'referer',
CREATED_AT: 'created_at',
LAST_ACTIVITY_AT: 'last_activity_at',
@@ -7,6 +7,7 @@ import {
buildAttributesFilterTypes,
CONVERSATION_ATTRIBUTES,
} from './helper/filterHelper';
import countries from 'shared/constants/countries.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
/**
@@ -217,6 +218,17 @@ export function useConversationFilterContext() {
filterOperators: equalityOperators.value,
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,
value: CONVERSATION_ATTRIBUTES.REFERER,
@@ -13,7 +13,6 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-ri-phone-fill',
};
@@ -61,12 +61,6 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-instagram');
});
it('returns correct icon for TikTok channel', () => {
const inbox = { channel_type: 'Channel::Tiktok' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-tiktok');
});
describe('TwilioSms channel', () => {
it('returns chat icon for regular Twilio SMS channel', () => {
const inbox = { channel_type: 'Channel::TwilioSms' };
@@ -28,7 +28,6 @@ import ImageBubble from './bubbles/Image.vue';
import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -318,7 +317,6 @@ const componentToRender = computed(() => {
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
}
// Attachment content is the name of the contact
@@ -42,10 +42,7 @@ const props = defineProps({
const emit = defineEmits(['retry']);
const allMessages = computed(() => {
return useCamelCase(props.messages, {
deep: true,
stopPaths: ['content_attributes.translations'],
});
return useCamelCase(props.messages, { deep: true });
});
const currentChat = useMapGetter('getSelectedChat');
@@ -20,7 +20,6 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
} = useInbox();
const {
@@ -61,8 +60,7 @@ const isSent = computed(() => {
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isAnInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -80,8 +78,7 @@ const isDelivered = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value ||
isATiktokChannel.value
isAFacebookInbox.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
@@ -103,8 +100,7 @@ const isRead = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value ||
isATiktokChannel.value
isAnInstagramChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -1,30 +0,0 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
<div
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
>
<iframe
class="w-full h-full border-0 rounded-lg"
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
:src="attachment.dataUrl"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
</BaseBubble>
</template>
@@ -1,28 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
buttonText: {
type: String,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -17,10 +17,6 @@ const { attachment } = defineProps({
type: Object,
required: true,
},
showTranscribedText: {
type: Boolean,
default: true,
},
});
defineOptions({
@@ -186,7 +182,7 @@ const downloadAudio = async () => {
</div>
<div
v-if="attachment.transcribedText && showTranscribedText"
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
@@ -49,7 +49,6 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
@@ -1,15 +1,14 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { h, computed, onMounted } from 'vue';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -53,7 +52,14 @@ const toggleShortcutModalFn = show => {
useSidebarKeyboardShortcuts(toggleShortcutModalFn);
const expandedItem = ref(null);
// We're using localStorage to store the expanded item in the sidebar
// This helps preserve context when navigating between portal and dashboard layouts
// and also when the user refreshes the page
const expandedItem = useStorage(
'next-sidebar-expanded-item',
null,
sessionStorage
);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
@@ -90,15 +96,6 @@ const closeMobileSidebar = () => {
emit('closeMobileSidebar');
};
const onComposeOpen = toggleFn => {
toggleFn();
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
};
const onComposeClose = () => {
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -484,12 +481,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -632,14 +623,14 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="onComposeOpen(toggle)"
@click="toggle"
/>
</template>
</ComposeConversation>
@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, watch, nextTick } from 'vue';
import { computed, onMounted, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -126,16 +126,6 @@ onMounted(async () => {
setExpandedItem(props.name);
}
});
watch(
hasActiveChild,
hasNewActiveChild => {
if (hasNewActiveChild && !isExpanded.value) {
setExpandedItem(props.name);
}
},
{ once: true }
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref, onMounted, nextTick, watch } from 'vue';
import { computed, ref, onMounted, nextTick } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({
@@ -31,24 +31,20 @@ const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
nextTick(() => {
if (!activeElement.value) return;
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
});
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, updateIndicator);
// Watch for prop/tabs changes to update indicator position
watch([() => props.initialActiveTab, () => props.tabs], updateIndicator, {
immediate: true,
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
@@ -70,7 +66,7 @@ const showDivider = index => {
<template>
<div
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 dark:bg-n-solid-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
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"
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"
@action="handleDropdownAction"
/>
</div>
@@ -1,239 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { toPng } from 'html-to-image';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
slideElement: {
type: Object,
default: null,
},
slideBackground: {
type: String,
required: true,
},
year: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const isGenerating = ref(false);
const shareImageUrl = ref(null);
const generateImage = async () => {
if (!props.slideElement) return;
isGenerating.value = true;
try {
let slideElement = props.slideElement;
if (slideElement && '$el' in slideElement) {
slideElement = slideElement.$el;
}
if (!slideElement) {
// eslint-disable-next-line no-console
console.error('No slide element found');
return;
}
const colorMap = {
'bg-[#5BD58A]': '#5BD58A',
'bg-[#60a5fa]': '#60a5fa',
'bg-[#fb923c]': '#fb923c',
'bg-[#f87171]': '#f87171',
'bg-[#fbbf24]': '#fbbf24',
};
const bgColor = colorMap[props.slideBackground] || '#ffffff';
const dataUrl = await toPng(slideElement, {
pixelRatio: 1.2,
backgroundColor: bgColor,
// Skip font/CSS embedding to avoid CORS issues with CDN stylesheets
// See: https://github.com/bubkoo/html-to-image/issues/49#issuecomment-762222100
fontEmbedCSS: '',
cacheBust: true,
});
const img = new Image();
img.src = dataUrl;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
const finalCanvas = document.createElement('canvas');
const borderSize = 20;
const bottomPadding = 50;
finalCanvas.width = img.width + borderSize * 2;
finalCanvas.height = img.height + borderSize * 2 + bottomPadding;
const ctx = finalCanvas.getContext('2d');
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, finalCanvas.width, finalCanvas.height);
ctx.drawImage(img, borderSize, borderSize);
ctx.fillStyle = '#1f2d3d';
ctx.font = 'normal 16px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'left';
ctx.fillText(
t('YEAR_IN_REVIEW.SHARE_MODAL.BRANDING'),
borderSize,
img.height + borderSize + 35
);
const logo = new Image();
logo.src = '/brand-assets/logo.svg';
await new Promise(resolve => {
logo.onload = resolve;
});
const logoHeight = 30;
const logoWidth = (logo.width / logo.height) * logoHeight;
const logoX = finalCanvas.width - borderSize - logoWidth;
const logoY = img.height + borderSize + 15;
ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight);
shareImageUrl.value = finalCanvas.toDataURL('image/png');
} catch (err) {
// Handle errors silently for now
// eslint-disable-next-line no-console
console.error('Failed to generate image:', err);
} finally {
isGenerating.value = false;
}
};
const downloadImage = () => {
if (!shareImageUrl.value) return;
const link = document.createElement('a');
link.href = shareImageUrl.value;
link.download = `chatwoot-year-in-review-${props.year}.png`;
link.click();
};
const shareImage = async () => {
if (!shareImageUrl.value) return;
try {
const response = await fetch(shareImageUrl.value);
const blob = await response.blob();
const file = new File([blob], `chatwoot-year-in-review-${props.year}.png`, {
type: 'image/png',
});
if (
navigator.share &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
await navigator.share({
title: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TITLE', {
year: props.year,
}),
text: t('YEAR_IN_REVIEW.SHARE_MODAL.SHARE_TEXT', { year: props.year }),
files: [file],
});
return;
}
downloadImage();
} catch (err) {
// Fallback to download if sharing fails
downloadImage();
}
};
const close = () => {
shareImageUrl.value = null;
emit('close');
};
const handleOpen = async () => {
if (props.show && !shareImageUrl.value) {
await generateImage();
}
};
defineExpose({ handleOpen });
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-[10001]"
@click="close"
>
<div v-if="isGenerating" class="flex items-center justify-center">
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-white border-t-transparent animate-spin"
/>
<p class="mt-4 text-sm text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.PREPARING') }}
</p>
</div>
</div>
<div
v-else-if="shareImageUrl"
class="max-w-2xl w-full mx-4 flex flex-col gap-6 bg-slate-800 rounded-2xl p-6"
@click.stop
>
<div class="flex items-center justify-between">
<h3 class="text-xl font-medium text-white">
{{ t('YEAR_IN_REVIEW.SHARE_MODAL.TITLE') }}
</h3>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
<div>
<img
:src="shareImageUrl"
alt="Year in Review"
class="w-full h-auto"
/>
</div>
<div class="flex gap-3">
<button
class="flex-[2] px-4 py-3 flex items-center justify-center gap-2 rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="downloadImage"
>
<i class="i-lucide-download w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.SHARE_MODAL.DOWNLOAD')
}}</span>
</button>
<button
class="w-10 h-10 flex items-center justify-center rounded-full text-white bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareImage"
>
<i class="i-lucide-share-2 w-5 h-5" />
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,88 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import YearInReviewModal from './YearInReviewModal.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const yearInReviewBannerImage =
'/assets/images/dashboard/year-in-review/year-in-review-sidebar.png';
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const getters = useStoreGetters();
const showModal = ref(false);
const modalRef = ref(null);
const currentYear = 2025;
const isACustomBrandedInstance =
getters['globalConfig/isACustomBrandedInstance'];
const bannerClosedKey = computed(() => {
const accountId = getters.getCurrentAccountId.value;
return `yir_closed_${accountId}_${currentYear}`;
});
const isBannerClosed = computed(() => {
return uiSettings.value?.[bannerClosedKey.value] === true;
});
const shouldShowBanner = computed(
() => !isBannerClosed.value && !isACustomBrandedInstance.value
);
const openModal = () => {
showModal.value = true;
};
const closeModal = () => {
showModal.value = false;
};
const closeBanner = event => {
event.stopPropagation();
updateUISettings({ [bannerClosedKey.value]: true });
};
</script>
<template>
<div v-if="shouldShowBanner" class="relative">
<div
class="mx-2 my-1 p-3 bg-n-iris-9 rounded-lg cursor-pointer hover:shadow-md transition-all"
@click="openModal"
>
<div class="flex items-start justify-between gap-2 mb-3">
<span
class="text-sm font-semibold text-white leading-tight tracking-tight flex-1"
>
{{ t('YEAR_IN_REVIEW.BANNER.TITLE', { year: currentYear }) }}
</span>
<button
class="inline-flex items-center justify-center rounded hover:bg-white hover:bg-opacity-20 transition-colors p-0"
@click="closeBanner"
>
<Icon
icon="i-lucide-x size-4 mt-0.5 text-n-slate-1 dark:text-n-slate-12"
/>
</button>
</div>
<div class="flex flex-col gap-3">
<img
:src="yearInReviewBannerImage"
alt="Year in Review"
class="w-full h-auto rounded"
/>
<button
class="w-full px-3 py-2 bg-white text-n-iris-9 text-xs font-medium rounded-mdtracking-tight"
@click.stop="openModal"
>
{{ t('YEAR_IN_REVIEW.BANNER.BUTTON') }}
</button>
</div>
</div>
<YearInReviewModal ref="modalRef" :show="showModal" @close="closeModal" />
</div>
</template>
@@ -1,389 +0,0 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import YearInReviewAPI from 'dashboard/api/yearInReview';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useStoreGetters } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { YEAR_IN_REVIEW_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntroSlide from './slides/IntroSlide.vue';
import ConversationsSlide from './slides/ConversationsSlide.vue';
import BusiestDaySlide from './slides/BusiestDaySlide.vue';
import PersonalitySlide from './slides/PersonalitySlide.vue';
import ThankYouSlide from './slides/ThankYouSlide.vue';
import ShareModal from './ShareModal.vue';
const props = defineProps({
show: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const { uiSettings } = useUISettings();
const getters = useStoreGetters();
const isOpen = ref(false);
const currentSlide = ref(0);
const yearData = ref(null);
const isLoading = ref(false);
const error = ref(null);
const slideRefs = ref([]);
const showShareModal = ref(false);
const shareModalRef = ref(null);
const drumrollAudio = ref(null);
const hasConversations = computed(() => {
return yearData.value?.total_conversations > 0;
});
const totalSlides = computed(() => {
if (!hasConversations.value) {
return 3;
}
return 5;
});
const slideIndexMap = computed(() => {
if (!hasConversations.value) {
return [0, 1, 4];
}
return [0, 1, 2, 3, 4];
});
const currentVisualSlide = computed(() => {
return slideIndexMap.value.indexOf(currentSlide.value);
});
const slideBackgrounds = [
'bg-[#5BD58A]',
'bg-[#60a5fa]',
'bg-[#fb923c]',
'bg-[#f87171]',
'bg-[#fbbf24]',
];
const playDrumroll = () => {
try {
if (!drumrollAudio.value) {
drumrollAudio.value = new Audio('/audio/dashboard/drumroll.mp3');
drumrollAudio.value.volume = 0.5;
}
drumrollAudio.value.currentTime = 0;
drumrollAudio.value.play().catch(err => {
// eslint-disable-next-line no-console
console.log('Could not play drumroll:', err);
});
} catch (err) {
// eslint-disable-next-line no-console
console.log('Error playing drumroll:', err);
}
};
const fetchYearInReviewData = async () => {
const year = 2025;
const accountId = getters.getCurrentAccountId.value;
const cacheKey = `year_in_review_${accountId}_${year}`;
const cachedData = uiSettings.value?.[cacheKey];
if (cachedData) {
yearData.value = cachedData;
return;
}
isLoading.value = true;
error.value = null;
try {
const response = await YearInReviewAPI.get(year);
yearData.value = response.data;
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
};
const nextSlide = () => {
if (currentSlide.value < 4) {
useTrack(YEAR_IN_REVIEW_EVENTS.NEXT_CLICKED);
if (!hasConversations.value && currentSlide.value === 1) {
currentSlide.value = 4;
} else {
currentSlide.value += 1;
}
}
};
const previousSlide = () => {
if (currentSlide.value > 0) {
if (!hasConversations.value && currentSlide.value === 4) {
currentSlide.value = 1;
} else {
currentSlide.value -= 1;
}
}
};
const goToSlide = visualIndex => {
currentSlide.value = slideIndexMap.value[visualIndex];
};
const close = () => {
currentSlide.value = 0;
isOpen.value = false;
yearData.value = null;
isLoading.value = false;
error.value = null;
emit('close');
};
const open = () => {
useTrack(YEAR_IN_REVIEW_EVENTS.MODAL_OPENED);
isOpen.value = true;
fetchYearInReviewData();
playDrumroll();
};
const currentSlideBackground = computed(
() => slideBackgrounds[currentSlide.value]
);
const shareCurrentSlide = async () => {
useTrack(YEAR_IN_REVIEW_EVENTS.SHARE_CLICKED);
showShareModal.value = true;
nextTick(() => {
if (shareModalRef.value) {
shareModalRef.value.handleOpen();
}
});
};
const closeShareModal = () => {
showShareModal.value = false;
};
const keyboardEvents = {
Escape: { action: close },
ArrowLeft: { action: previousSlide },
ArrowRight: { action: nextSlide },
};
useKeyboardEvents(keyboardEvents);
defineExpose({ open, close });
watch(
() => props.show,
newValue => {
if (newValue) {
open();
}
}
);
</script>
<template>
<Teleport to="body">
<div
v-if="isOpen"
class="fixed inset-0 z-[9999] bg-black font-interDisplay"
>
<div class="relative w-full h-full overflow-hidden">
<div
v-if="isLoading"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<div
class="inline-block w-12 h-12 border-4 rounded-full border-n-slate-6 border-t-n-slate-11 animate-spin"
/>
<p class="mt-4 text-sm text-n-slate-11">
{{ t('YEAR_IN_REVIEW.LOADING') }}
</p>
</div>
</div>
<div
v-else-if="error"
class="flex items-center justify-center w-full h-full bg-n-slate-2"
>
<div class="text-center">
<p class="text-lg font-semibold text-red-600">
{{ t('YEAR_IN_REVIEW.ERROR') }}
</p>
<p class="mt-2 text-sm text-n-slate-11">{{ error }}</p>
<button
class="mt-4 px-4 py-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="close"
>
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.CLOSE')
}}</span>
</button>
</div>
</div>
<div
v-else-if="yearData"
class="relative w-full h-full"
:class="currentSlideBackground"
>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<IntroSlide
v-if="currentSlide === 0"
:key="0"
:ref="el => (slideRefs[0] = el)"
:year="yearData.year"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ConversationsSlide
v-if="currentSlide === 1"
:key="1"
:ref="el => (slideRefs[1] = el)"
:total-conversations="yearData.total_conversations"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<BusiestDaySlide
v-if="
currentSlide === 2 && hasConversations && yearData.busiest_day
"
:key="2"
:ref="el => (slideRefs[2] = el)"
:busiest-day="yearData.busiest_day"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<PersonalitySlide
v-if="
currentSlide === 3 &&
hasConversations &&
yearData.support_personality
"
:key="3"
:ref="el => (slideRefs[3] = el)"
:support-personality="yearData.support_personality"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-x-[30px]"
leave-to-class="opacity-0 -translate-x-[30px]"
>
<ThankYouSlide
v-if="currentSlide === 4"
:key="4"
:ref="el => (slideRefs[4] = el)"
:year="yearData.year"
/>
</Transition>
<div
class="absolute bottom-8 left-0 right-0 flex items-center justify-between px-8"
>
<button
v-if="currentSlide > 0"
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="previousSlide"
>
<i class="i-lucide-chevron-left w-5 h-5" />
<span class="text-sm font-medium">
{{ t('YEAR_IN_REVIEW.NAVIGATION.PREVIOUS') }}
</span>
</button>
<div v-else class="w-20" />
<div class="flex gap-2">
<button
v-for="index in totalSlides"
:key="index"
class="w-2 h-2 rounded-full transition-all"
:class="
currentVisualSlide === index - 1
? 'bg-white w-8'
: 'bg-white bg-opacity-50'
"
@click="goToSlide(index - 1)"
/>
</div>
<button
class="px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
:class="{ invisible: currentVisualSlide === totalSlides - 1 }"
@click="nextSlide"
>
<span
v-if="currentVisualSlide < totalSlides - 1"
class="text-sm font-medium"
>
{{ t('YEAR_IN_REVIEW.NAVIGATION.NEXT') }}
</span>
<i
v-if="currentVisualSlide < totalSlides - 1"
class="i-lucide-chevron-right w-5 h-5"
/>
</button>
</div>
<button
class="absolute top-4 left-4 px-4 py-2 flex items-center gap-2 rounded-full text-n-slate-12 dark:text-n-slate-1 bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors"
@click="shareCurrentSlide"
>
<i class="i-lucide-share-2 w-5 h-5" />
<span class="text-sm font-medium">{{
t('YEAR_IN_REVIEW.NAVIGATION.SHARE')
}}</span>
</button>
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full text-n-slate-12 dark:text-n-slate-1 hover:bg-white hover:bg-opacity-20 transition-colors"
@click="close"
>
<i class="i-lucide-x w-6 h-6" />
</button>
</div>
</div>
</div>
<ShareModal
ref="shareModalRef"
:show="showShareModal"
:slide-element="slideRefs[currentSlide]"
:slide-background="currentSlideBackground"
:year="yearData?.year"
@close="closeShareModal"
/>
</Teleport>
</template>
@@ -1,76 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
busiestDay: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const coffeeImage =
'/assets/images/dashboard/year-in-review/third-frame-coffee.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const performanceHelperText = computed(() => {
const count = props.busiestDay.count;
if (count <= 5) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.0_5');
if (count <= 10) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.5_10');
if (count <= 25) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.10_25');
if (count <= 50) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.25_50');
if (count <= 100) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.100_500');
return t('YEAR_IN_REVIEW.BUSIEST_DAY.COMPARISON.500_PLUS');
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-4 w-full max-w-3xl">
<div class="flex items-center justify-center flex-1">
<div class="flex items-center justify-between gap-6 flex-1 md:gap-16">
<div class="text-white flex gap-2 flex-col">
<div class="text-2xl lg:text-3xl xl:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.BUSIEST_DAY.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[140px] tracking-tighter">
{{ busiestDay.date }}
</div>
</div>
<img
:src="coffeeImage"
alt="Coffee"
class="w-auto h-32 md:h-56 lg:h-72"
/>
</div>
</div>
<div class="flex flex-col gap-2 flex-1">
<div class="flex items-center justify-center gap-3 md:gap-8">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<div class="flex-1">
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.BUSIEST_DAY.MESSAGE', {
count: busiestDay.count,
})
}}
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
totalConversations: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const cloudImage =
'/assets/images/dashboard/year-in-review/second-frame-cloud-icon.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const hasData = computed(() => {
return props.totalConversations > 0;
});
const formatNumber = num => {
if (num >= 100000) {
return '100k+';
}
return new Intl.NumberFormat().format(num);
};
const performanceHelperText = computed(() => {
if (!hasData.value) {
return t('YEAR_IN_REVIEW.CONVERSATIONS.FALLBACK');
}
const count = props.totalConversations;
if (count <= 50) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.0_50');
if (count <= 100) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.50_100');
if (count <= 500) return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.100_500');
if (count <= 2000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.500_2000');
if (count <= 10000)
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.2000_10000');
return t('YEAR_IN_REVIEW.CONVERSATIONS.COMPARISON.10000_PLUS');
});
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div
class="flex flex-col gap-16"
:class="totalConversations > 100 ? 'max-w-4xl' : 'max-w-3xl'"
>
<div class="flex items-center justify-center flex-1">
<div
class="flex items-center justify-between gap-6 flex-1"
:class="totalConversations > 100 ? 'md:gap-16' : 'md:gap-8'"
>
<div class="text-white flex gap-3 flex-col">
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.TITLE') }}
</div>
<div class="text-6xl md:text-8xl lg:text-[180px] tracking-tighter">
{{ formatNumber(totalConversations) }}
</div>
<div class="text-2xl md:text-3xl lg:text-4xl tracking-tight -mt-2">
{{ t('YEAR_IN_REVIEW.CONVERSATIONS.SUBTITLE') }}
</div>
</div>
<img
:src="cloudImage"
alt="Cloud"
class="w-auto h-32 md:h-56 lg:h-80 -mr-2"
/>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-4xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ performanceHelperText }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const candlesImagePath =
'/assets/images/dashboard/year-in-review/first-frame-candles.png';
const { t } = useI18n();
</script>
<template>
<div
class="absolute inset-0 flex flex-col items-center justify-center text-black px-8 md:px-16 lg:px-24 py-10 md:py-16 lg:py-20 bg-cover bg-center min-h-[700px]"
:style="{
backgroundImage: `url('/assets/images/dashboard/year-in-review/first-frame-bg.png')`,
}"
>
<div class="text-center max-w-3xl">
<h1
class="text-8xl md:text-9xl lg:text-[220px] font-semibold mb-4 md:mb-6 leading-none tracking-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ year }}
</h1>
<h2
class="text-3xl md:text-4xl lg:text-5xl font-medium mb-12 md:mb-16 lg:mb-20 text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.TITLE') }}
</h2>
</div>
<img
:src="candlesImagePath"
alt="Candles"
class="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-auto h-32 md:h-48 lg:h-64"
/>
</div>
</template>
@@ -1,99 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportPersonality: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const clockImage =
'/assets/images/dashboard/year-in-review/fourth-frame-clock.png';
const doubleQuotesImage =
'/assets/images/dashboard/year-in-review/double-quotes.png';
const formatResponseTime = seconds => {
if (seconds < 60) {
return 'less than a minute';
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
if (seconds < 86400) {
const hours = Math.floor(seconds / 3600);
return hours === 1 ? '1 hour' : `${hours} hours`;
}
return 'more than a day';
};
const personality = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const minutes = seconds / 60;
if (minutes < 2) {
return 'Swift Helper';
}
if (minutes < 5) {
return 'Quick Responder';
}
if (minutes < 15) {
return 'Steady Support';
}
return 'Thoughtful Advisor';
});
const personalityMessage = computed(() => {
const seconds = props.supportPersonality.avg_response_time_seconds;
const time = formatResponseTime(seconds);
const personalityKeyMap = {
'Swift Helper': 'SWIFT_HELPER',
'Quick Responder': 'QUICK_RESPONDER',
'Steady Support': 'STEADY_SUPPORT',
'Thoughtful Advisor': 'THOUGHTFUL_ADVISOR',
};
const key = personalityKeyMap[personality.value];
if (!key) return '';
return t(`YEAR_IN_REVIEW.PERSONALITY.MESSAGES.${key}`, { time });
});
</script>
<template>
<div class="absolute inset-0 flex items-center justify-center px-8 md:px-32">
<div class="flex flex-col gap-9 max-w-3xl">
<div class="mb-4 md:mb-6">
<img :src="clockImage" alt="Clock" class="w-auto h-28" />
<div class="flex items-center justify-start flex-1 mt-9">
<div class="text-n-slate-1 dark:text-n-slate-12 flex gap-3 flex-col">
<div class="text-2xl md:text-4xl tracking-tight">
{{ t('YEAR_IN_REVIEW.PERSONALITY.TITLE') }}
</div>
<div class="text-6xl md:text-7xl lg:text-8xl tracking-tighter">
{{ personality }}
</div>
</div>
</div>
</div>
<div class="flex items-center justify-center gap-3 md:gap-6">
<img
:src="doubleQuotesImage"
alt="Quote"
class="w-8 h-8 md:w-12 md:h-12 lg:w-16 lg:h-16"
/>
<p
class="text-xl md:text-3xl lg:text-3xl font-medium tracking-[-0.2px] text-n-slate-12 dark:text-n-slate-1"
>
{{ personalityMessage }}
</p>
</div>
</div>
</div>
</template>
@@ -1,43 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
year: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const signatureImage =
'/assets/images/dashboard/year-in-review/fifth-frame-signature.png';
</script>
<template>
<div
class="absolute inset-0 flex items-center justify-center px-8 md:px-32 py-20"
>
<div class="flex flex-col items-start max-w-4xl">
<div
class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight !leading-tight text-n-slate-12 dark:text-n-slate-1"
>
{{ t('YEAR_IN_REVIEW.THANK_YOU.TITLE', { year }) }}
</div>
<div
class="text-xl lg:text-3xl mt-8 font-medium !leading-snug text-n-slate-12 dark:text-n-slate-1"
>
{{
t('YEAR_IN_REVIEW.THANK_YOU.MESSAGE', { nextYear: Number(year) + 1 })
}}
</div>
<div class="mt-12">
<img
:src="signatureImage"
alt="Chatwoot Team Signature"
class="w-auto h-8 md:h-10"
/>
</div>
</div>
</div>
</template>
@@ -206,14 +206,10 @@ const emitDateRange = () => {
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
const closeDatePicker = () => {
showDatePicker.value = false;
};
</script>
<template>
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<div class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
@@ -23,10 +23,6 @@ const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
@@ -48,10 +44,6 @@ const isActive = computed(() => {
);
}
if (key === 'tiktok') {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
@@ -65,7 +57,6 @@ const isActive = computed(() => {
'telegram',
'line',
'instagram',
'tiktok',
'voice',
].includes(key);
});
@@ -1,184 +0,0 @@
<script setup>
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { useCallSession } from 'dashboard/composables/useCallSession';
import WindowVisibilityHelper from 'dashboard/helper/AudioAlerts/WindowVisibilityHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const router = useRouter();
const store = useStore();
const {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
joinCall,
endCall: endCallSession,
rejectIncomingCall,
dismissCall,
formattedCallDuration,
} = useCallSession();
const getCallInfo = call => {
const conversation = store.getters.getConversationById(call?.conversationId);
const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id);
const sender = conversation?.meta?.sender;
return {
conversation,
inbox,
contactName: sender?.name || sender?.phone_number || 'Unknown caller',
inboxName: inbox?.name || 'Customer support',
avatar: sender?.avatar || sender?.thumbnail,
};
};
const handleEndCall = async () => {
const call = activeCall.value;
if (!call) return;
const inboxId = call.inboxId || getCallInfo(call).conversation?.inbox_id;
if (!inboxId) return;
await endCallSession({
conversationId: call.conversationId,
inboxId,
});
};
const handleJoinCall = async call => {
const { conversation } = getCallInfo(call);
if (!call || !conversation || isJoining.value) return;
// End current active call before joining new one
if (hasActiveCall.value) {
await handleEndCall();
}
const result = await joinCall({
conversationId: call.conversationId,
inboxId: conversation.inbox_id,
callSid: call.callSid,
});
if (result) {
router.push({
name: 'inbox_conversation',
params: { conversation_id: call.conversationId },
});
}
};
// Auto-join outbound calls when window is visible
watch(
() => incomingCalls.value[0],
call => {
if (
call?.callDirection === 'outbound' &&
!hasActiveCall.value &&
WindowVisibilityHelper.isWindowVisible()
) {
handleJoinCall(call);
}
},
{ immediate: true }
);
</script>
<template>
<div
v-if="incomingCalls.length || hasActiveCall"
class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-2 w-72"
>
<!-- Incoming Calls (shown above active call) -->
<div
v-for="call in hasActiveCall ? incomingCalls : []"
:key="call.callSid"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div class="animate-pulse ring-2 ring-n-teal-9 rounded-full inline-flex">
<Avatar
:src="getCallInfo(call).avatar"
:name="getCallInfo(call).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(call).contactName }}
</p>
<p class="text-xs text-n-slate-11 truncate">
{{ getCallInfo(call).inboxName }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="dismissCall(call.callSid)"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(call)"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
<!-- Main Call Widget -->
<div
v-if="hasActiveCall || incomingCalls.length"
class="flex items-center gap-3 p-4 bg-n-solid-2 rounded-xl shadow-xl outline outline-1 outline-n-strong"
>
<div
class="ring-2 ring-n-teal-9 rounded-full inline-flex"
:class="{ 'animate-pulse': !hasActiveCall }"
>
<Avatar
:src="getCallInfo(activeCall || incomingCalls[0]).avatar"
:name="getCallInfo(activeCall || incomingCalls[0]).contactName"
:size="40"
rounded-full
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-n-slate-12 truncate mb-0">
{{ getCallInfo(activeCall || incomingCalls[0]).contactName }}
</p>
<p v-if="hasActiveCall" class="font-mono text-sm text-n-teal-9">
{{ formattedCallDuration }}
</p>
<p v-else class="text-xs text-n-slate-11">
{{
incomingCalls[0]?.callDirection === 'outbound'
? $t('CONVERSATION.VOICE_WIDGET.OUTGOING_CALL')
: $t('CONVERSATION.VOICE_WIDGET.INCOMING_CALL')
}}
</p>
</div>
<div class="flex shrink-0 gap-2">
<button
class="flex justify-center items-center w-10 h-10 bg-n-ruby-9 hover:bg-n-ruby-10 rounded-full transition-colors"
@click="
hasActiveCall
? handleEndCall()
: rejectIncomingCall(incomingCalls[0]?.callSid)
"
>
<i class="text-lg text-white i-ph-phone-x-bold" />
</button>
<button
v-if="!hasActiveCall"
class="flex justify-center items-center w-10 h-10 bg-n-teal-9 hover:bg-n-teal-10 rounded-full transition-colors"
@click="handleJoinCall(incomingCalls[0])"
>
<i class="text-lg text-white i-ph-phone-bold" />
</button>
</div>
</div>
</div>
</template>
@@ -38,7 +38,7 @@ const toggleShowMore = () => {
<button
v-if="text.length > limit"
class="text-n-brand !p-0 !border-0 align-top"
@click.stop="toggleShowMore"
@click="toggleShowMore"
>
{{ buttonLabel }}
</button>
@@ -55,7 +55,6 @@ import {
getSelectionCoords,
calculateMenuPosition,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
import {
hasPressedEnterAndNotCmdOrShift,
@@ -132,10 +131,8 @@ const editorMenuOptions = computed(() => {
const createState = (content, placeholder, plugins = [], methods = {}) => {
const schema = editorSchema.value;
// Strip unsupported formatting before parsing to prevent "Token type not supported" errors
const sanitizedContent = stripUnsupportedFormatting(content, schema);
return EditorState.create({
doc: new MessageMarkdownTransformer(schema).parse(sanitizedContent),
doc: new MessageMarkdownTransformer(schema).parse(content),
plugins: buildEditor({
schema,
placeholder,
@@ -679,18 +676,11 @@ function createEditorView() {
typingIndicator.stop();
emit('blur');
},
paste: (view, event) => {
paste: (_view, event) => {
if (props.disabled) return;
const { files } = event.clipboardData;
if (!files.length) return;
event.preventDefault();
// Paste text content alongside files (e.g., spreadsheet data from Numbers app)
// Numbers app includes invalid 0-byte attachments with text, so we paste the text here
// while ReplyBox filters and handles valid file attachments
const text = event.clipboardData.getData('text/plain');
if (text) {
view.dispatch(view.state.tr.insertText(text));
emitOnChange();
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
}
},
},
@@ -862,7 +852,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
max-height: none !important;
min-height: 0 !important;
padding: 0 !important;
display: none !important;
}
> .ProseMirror {
@@ -41,9 +41,6 @@ const getTemplateType = template => {
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
}
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) {
return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION');
}
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
};
@@ -205,9 +205,6 @@ export default {
if (this.isAWhatsAppCloudChannel) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (this.isATiktokChannel) {
return REPLY_POLICY.TIKTOK;
}
if (!this.isAPIInbox) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
@@ -221,9 +218,6 @@ export default {
) {
return this.$t('CONVERSATION.24_HOURS_WINDOW');
}
if (this.isATiktokChannel) {
return this.$t('CONVERSATION.48_HOURS_WINDOW');
}
if (!this.isAPIInbox) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
@@ -7,7 +7,9 @@ import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
import ReplyEmailHead from './ReplyEmailHead.vue';
@@ -44,8 +46,8 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
extractTextFromMarkdown,
} from 'dashboard/helper/editorHelper';
import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -70,6 +72,8 @@ export default {
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
ResizableTextArea,
CannedResponse,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -110,6 +114,8 @@ export default {
recordingAudioState: '',
recordingAudioDurationText: '',
replyType: REPLY_EDITOR_MODES.REPLY,
mentionSearchKey: '',
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
toEmails: '',
@@ -142,6 +148,9 @@ export default {
if (!senderId) return {};
return this.$store.getters['contacts/getContact'](senderId);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
},
shouldShowReplyToMessage() {
return (
this.inReplyTo?.id &&
@@ -225,9 +234,6 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
@@ -400,6 +406,19 @@ export default {
!!this.quotedEmailText
);
},
showRichContentEditor() {
if (this.isOnPrivateNote || this.isRichEditorEnabled) {
return true;
}
return false;
},
// ensure that the signature is plain text depending on `showRichContentEditor`
signatureToApply() {
return this.showRichContentEditor
? this.messageSignature
: extractTextFromMarkdown(this.messageSignature);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -442,7 +461,25 @@ export default {
this.resetRecorderAndClearAttachments();
}
},
message() {
message(updatedMessage) {
// Check if the message starts with a slash.
const bodyWithoutSignature = removeSignature(
updatedMessage,
this.signatureToApply
);
const startsWithSlash = bodyWithoutSignature.startsWith('/');
// Determine if the user is potentially typing a slash command.
// This is true if the message starts with a slash and the rich content editor is not active.
this.hasSlashCommand = startsWithSlash && !this.showRichContentEditor;
this.showMentions = this.hasSlashCommand;
// If a slash command is active, extract the command text after the slash.
// If not, reset the mentionSearchKey.
this.mentionSearchKey = this.hasSlashCommand
? bodyWithoutSignature.substring(1)
: '';
// Autosave the current message draft.
this.doAutoSaveDraft();
},
@@ -492,14 +529,20 @@ export default {
methods: {
handleInsert(article) {
const { url, title } = article;
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
if (this.isRichEditorEnabled) {
// Removing empty lines from the title
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
} else {
this.addIntoEditor(
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
useTrack(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
@@ -568,14 +611,26 @@ export default {
if (this.isPrivate) {
return message;
}
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
return this.sendWithSignature
? appendSignature(
message,
this.messageSignature,
effectiveChannelType
)
: removeSignature(
message,
this.messageSignature,
effectiveChannelType
);
}
return this.sendWithSignature
? appendSignature(message, this.messageSignature, effectiveChannelType)
: removeSignature(message, this.messageSignature, effectiveChannelType);
? appendSignature(message, this.signatureToApply)
: removeSignature(message, this.signatureToApply);
},
removeFromDraft() {
if (this.conversationIdByRoute) {
@@ -591,6 +646,7 @@ export default {
Escape: {
action: () => {
this.hideEmojiPicker();
this.hideMentions();
},
allowOnFocusedInput: true,
},
@@ -632,35 +688,17 @@ export default {
);
},
onPaste(e) {
// Don't handle paste if compose new conversation modal is open
if (this.newConversationModalActive) return;
// Filter valid files (non-zero size)
Array.from(e.clipboardData.files)
.filter(file => file.size > 0)
.filter(file => {
const isAllowed = isFileTypeAllowedForChannel(file, {
channelType: this.channelType || this.inbox?.channel_type,
medium: this.inbox?.medium,
conversationType: this.conversationType,
isInstagramChannel: this.isAnInstagramChannel,
isOnPrivateNote: this.isOnPrivateNote,
});
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 });
});
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
this.$refs.messageInput?.$el?.blur();
}
if (!data.length || !data[0]) {
return;
}
data.forEach(file => {
const { name, type, size } = file;
this.onFileUpload({ name, type, size, file: file });
});
},
toggleUserMention(currentMentionState) {
this.showUserMentions = currentMentionState;
@@ -787,15 +825,19 @@ export default {
// if signature is enabled, append it to the message
// appendSignature ensures that the signature is not duplicated
// so we don't need to check if the signature is already present
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
message = appendSignature(
message,
this.messageSignature,
effectiveChannelType
);
} else {
message = appendSignature(message, this.signatureToApply);
}
}
const updatedMessage = replaceVariablesInMessage({
@@ -819,30 +861,52 @@ export default {
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
}
return;
}
this.$nextTick(() => this.$refs.messageInput.focus());
},
clearEditorSelection() {
this.updateEditorSelectionWith = '';
},
insertIntoTextEditor(text, selectionStart, selectionEnd) {
const { message } = this;
const newMessage =
message.slice(0, selectionStart) +
text +
message.slice(selectionEnd, message.length);
this.message = newMessage;
},
addIntoEditor(content) {
this.updateEditorSelectionWith = content;
this.onFocus();
if (this.showRichContentEditor) {
this.updateEditorSelectionWith = content;
this.onFocus();
}
if (!this.showRichContentEditor) {
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
}
},
clearMessage() {
this.message = '';
if (this.sendWithSignature && !this.isPrivate) {
// if signature is enabled, append it to the message
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
if (this.showRichContentEditor) {
const effectiveChannelType = getEffectiveChannelType(
this.channelType,
this.inbox?.medium || ''
);
this.message = appendSignature(
this.message,
this.messageSignature,
effectiveChannelType
);
} else {
this.message = appendSignature(this.message, this.signatureToApply);
}
}
this.attachedFiles = [];
this.isRecordingAudio = false;
@@ -877,6 +941,9 @@ export default {
this.toggleEmojiPicker();
}
},
hideMentions() {
this.showMentions = false;
},
onTypingOn() {
this.toggleTyping('on');
},
@@ -1123,6 +1190,13 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<CannedResponse
v-if="showMentions && hasSlashCommand"
v-on-clickaway="hideMentions"
class="normal-editor__canned-box"
:search-key="mentionSearchKey"
@replace="replaceText"
/>
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
@@ -1146,7 +1220,23 @@ export default {
@play="recordingAudioState = 'playing'"
@pause="recordingAudioState = 'paused'"
/>
<ResizableTextArea
v-else-if="!showRichContentEditor"
ref="messageInput"
v-model="message"
class="rounded-none input"
:placeholder="messagePlaceHolder"
:min-height="4"
:signature="signatureToApply"
allow-signature
:send-with-signature="sendWithSignature"
@typing-off="onTypingOff"
@typing-on="onTypingOn"
@focus="onFocus"
@blur="onBlur"
/>
<WootMessageEditor
v-else
v-model="message"
:editor-id="editorStateId"
class="input popover-prosemirror-menu"
@@ -1272,6 +1362,10 @@ export default {
.reply-box__top {
@apply relative py-0 px-4 -mt-px;
textarea {
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
}
}
.emoji-dialog {
@@ -1291,4 +1385,9 @@ export default {
@apply ltr:left-1 rtl:right-1 -bottom-2;
}
}
.normal-editor__canned-box {
width: calc(100% - 2 * 1rem);
left: 1rem;
}
</style>
@@ -78,6 +78,14 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'additional',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
@@ -163,6 +171,10 @@ export const filterAttributeGroups = [
key: 'browser_language',
i18nKey: 'BROWSER_LANGUAGE',
},
{
key: 'country_code',
i18nKey: 'COUNTRY_NAME',
},
{
key: 'referer',
i18nKey: 'REFERER_LINK',
@@ -1,7 +1,6 @@
<script setup>
import { ref, watch, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({
items: {
@@ -16,8 +15,6 @@ const props = defineProps({
const emit = defineEmits(['mentionSelect']);
const { getPlainText } = useMessageFormatter();
const mentionsListContainerRef = ref(null);
const selectedIndex = ref(0);
@@ -97,7 +94,7 @@ const variableKey = (item = {}) => {
'text-n-slate-12': index === selectedIndex,
}"
>
{{ getPlainText(item.description) }}
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-n-slate-11 group-hover:text-n-slate-12 text-ellipsis whitespace-nowrap"
@@ -48,7 +48,6 @@ const mockStore = createStore({
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
},
@@ -216,12 +215,6 @@ describe('useInbox', () => {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
});
});
@@ -273,7 +266,6 @@ describe('useInbox', () => {
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
];

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