Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b0eeb8d09 |
@@ -11,9 +11,6 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
|
||||
@@ -8,9 +8,6 @@ on:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -10,9 +10,6 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -14,9 +14,6 @@ on:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish-code-space-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,9 +18,6 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -18,9 +18,6 @@ on:
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
|
||||
@@ -10,9 +10,6 @@ concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -7,9 +7,6 @@ on:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
strategy:
|
||||
|
||||
@@ -22,7 +22,6 @@ gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
gem 'gemoji'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
@@ -85,7 +84,6 @@ gem 'barnes'
|
||||
gem 'devise', '>= 4.9.4'
|
||||
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
|
||||
gem 'devise_token_auth', '>= 1.2.3'
|
||||
gem 'rails-i18n', '~> 7.0'
|
||||
# two-factor authentication
|
||||
gem 'devise-two-factor', '>= 5.0.0'
|
||||
# authorization
|
||||
|
||||
+3
-9
@@ -108,8 +108,8 @@ GEM
|
||||
acts-as-taggable-on (12.0.0)
|
||||
activerecord (>= 7.1, < 8.1)
|
||||
zeitwerk (>= 2.4, < 3.0)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
administrate (0.20.1)
|
||||
actionpack (>= 6.0, < 8.0)
|
||||
actionview (>= 6.0, < 8.0)
|
||||
@@ -349,7 +349,6 @@ GEM
|
||||
googleapis-common-protos-types (>= 1.3.1, < 2.a)
|
||||
googleauth (~> 1.0)
|
||||
grpc (~> 1.36)
|
||||
gemoji (4.1.0)
|
||||
geocoder (1.8.1)
|
||||
gli (2.22.2)
|
||||
ostruct
|
||||
@@ -677,7 +676,7 @@ GEM
|
||||
method_source (~> 1.0)
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (7.0.5)
|
||||
public_suffix (6.0.2)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
@@ -728,9 +727,6 @@ GEM
|
||||
rails-html-sanitizer (1.6.1)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (7.0.10)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.1.5.2)
|
||||
actionpack (= 7.1.5.2)
|
||||
activesupport (= 7.1.5.2)
|
||||
@@ -1076,7 +1072,6 @@ DEPENDENCIES
|
||||
fcm
|
||||
flag_shih_tzu
|
||||
foreman
|
||||
gemoji
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
@@ -1130,7 +1125,6 @@ DEPENDENCIES
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.1)
|
||||
rails-i18n (~> 7.0)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
|
||||
@@ -44,11 +44,7 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_account
|
||||
@account = Account.create!(
|
||||
name: account_name,
|
||||
locale: I18n.locale,
|
||||
custom_attributes: { 'onboarding_step' => 'account_details' }
|
||||
)
|
||||
@account = Account.create!(name: account_name, locale: I18n.locale)
|
||||
Current.account = @account
|
||||
end
|
||||
|
||||
|
||||
@@ -29,9 +29,8 @@ class AgentBuilder
|
||||
user = User.from_email(email)
|
||||
return user if user
|
||||
|
||||
@name = email.split('@').first if @name.blank?
|
||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||
User.create!(email: email, name: @name, password: temp_password, password_confirmation: temp_password)
|
||||
User.create!(email: email, name: name, password: temp_password, password_confirmation: temp_password)
|
||||
end
|
||||
|
||||
# Checks if the user needs confirmation.
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
end
|
||||
|
||||
def update_status
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(status: params[:status]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@articles.destroy_all
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Article, :create?)
|
||||
end
|
||||
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
sort_on :phone_number, type: :string
|
||||
sort_on :last_activity_at, internal_name: :order_on_last_activity_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :created_at, internal_name: :order_on_created_at, type: :scope, scope_params: [:direction]
|
||||
sort_on :company_name, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :company, internal_name: :order_on_company_name, type: :scope, scope_params: [:direction]
|
||||
sort_on :city, internal_name: :order_on_city, type: :scope, scope_params: [:direction]
|
||||
sort_on :country, internal_name: :order_on_country_name, type: :scope, scope_params: [:direction]
|
||||
|
||||
|
||||
@@ -61,8 +61,9 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def process_attached_logo
|
||||
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
|
||||
@portal.logo.attach(blob) if blob
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||
@portal.logo.attach(blob)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -58,7 +58,6 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||
@account.custom_attributes.merge!(custom_attributes_params)
|
||||
@account.settings.merge!(settings_params)
|
||||
@account.custom_attributes.delete('onboarding_step') if @account.custom_attributes['onboarding_step'] == 'account_details'
|
||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||
@account.save!
|
||||
end
|
||||
@@ -72,10 +71,9 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def enqueue_branding_enrichment
|
||||
email = account_params[:email].presence || @user&.email
|
||||
return if email.blank?
|
||||
return if account_params[:email].blank?
|
||||
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, email)
|
||||
Account::BrandingEnrichmentJob.perform_later(@account.id, account_params[:email])
|
||||
Redis::Alfred.set(format(Redis::Alfred::ACCOUNT_ONBOARDING_ENRICHMENT, account_id: @account.id), '1', ex: 30)
|
||||
rescue StandardError => e
|
||||
# Enrichment is optional — never let queue/Redis failures abort signup
|
||||
@@ -111,7 +109,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def custom_attributes_params
|
||||
params.permit(:industry, :company_size, :timezone, :referral_source, :user_role)
|
||||
params.permit(:industry, :company_size, :timezone)
|
||||
end
|
||||
|
||||
def settings_params
|
||||
|
||||
@@ -2,8 +2,8 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
before_action :check_mfa_feature_available
|
||||
before_action :check_mfa_enabled, only: [:destroy, :backup_codes]
|
||||
before_action :check_mfa_disabled, only: [:create, :verify]
|
||||
before_action :validate_password, only: [:destroy]
|
||||
before_action :validate_otp, only: [:verify, :backup_codes, :destroy]
|
||||
before_action :validate_password, only: [:destroy]
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -48,8 +48,7 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
def validate_otp
|
||||
authenticated = Mfa::AuthenticationService.new(
|
||||
user: current_user,
|
||||
otp_code: mfa_params[:otp_code],
|
||||
backup_code: mfa_params[:backup_code]
|
||||
otp_code: mfa_params[:otp_code]
|
||||
).authenticate
|
||||
|
||||
return if authenticated
|
||||
@@ -64,6 +63,6 @@ class Api::V1::Profile::MfaController < Api::BaseController
|
||||
end
|
||||
|
||||
def mfa_params
|
||||
params.permit(:otp_code, :backup_code, :password)
|
||||
params.permit(:otp_code, :password)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
include Events::Types
|
||||
before_action :render_not_found_if_empty,
|
||||
only: [:toggle_typing, :toggle_status, :request_handoff, :set_custom_attributes, :destroy_custom_attributes]
|
||||
before_action :render_not_found_if_empty, only: [:toggle_typing, :toggle_status, :set_custom_attributes, :destroy_custom_attributes]
|
||||
|
||||
def index
|
||||
@conversation = conversation
|
||||
@@ -65,11 +64,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def request_handoff
|
||||
perform_handoff if conversation.pending?
|
||||
head :ok
|
||||
end
|
||||
|
||||
def set_custom_attributes
|
||||
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
|
||||
end
|
||||
@@ -105,40 +99,4 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
end
|
||||
|
||||
def perform_handoff
|
||||
assistant = captain_assistant
|
||||
previous_executed_by = Current.executed_by
|
||||
Current.executed_by = assistant if assistant
|
||||
|
||||
I18n.with_locale(conversation.account.locale) do
|
||||
create_captain_handoff_message(assistant) if assistant
|
||||
conversation.bot_handoff!
|
||||
send_out_of_office_message_after_handoff
|
||||
end
|
||||
ensure
|
||||
Current.executed_by = previous_executed_by
|
||||
end
|
||||
|
||||
def captain_assistant
|
||||
return unless inbox.respond_to?(:captain_assistant)
|
||||
|
||||
inbox.captain_assistant
|
||||
end
|
||||
|
||||
def create_captain_handoff_message(assistant)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
sender: assistant,
|
||||
content: assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
||||
)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_after_handoff
|
||||
return if conversation.campaign.present?
|
||||
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
module TwilioSignatureVerifyConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :verify_twilio_signature!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_twilio_signature!
|
||||
channel = find_twilio_channel
|
||||
return log_and_reject_missing_channel if channel.blank?
|
||||
return if channel.api_key_sid.present? && log_api_key_skip(channel)
|
||||
|
||||
head :forbidden unless valid_signature?(channel)
|
||||
end
|
||||
|
||||
def log_and_reject_missing_channel
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Channel not found for webhook ' \
|
||||
"account_sid=#{params[:AccountSid]} messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"to=#{params[:To]} from=#{params[:From]}"
|
||||
)
|
||||
head :forbidden
|
||||
end
|
||||
|
||||
def log_api_key_skip(channel)
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Signature validation skipped: channel uses API key authentication. ' \
|
||||
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id}"
|
||||
)
|
||||
end
|
||||
|
||||
def valid_signature?(channel)
|
||||
signature = request.headers['X-Twilio-Signature']
|
||||
if signature.blank?
|
||||
Rails.logger.warn("[TWILIO] Missing X-Twilio-Signature header account_sid=#{params[:AccountSid]}")
|
||||
return false
|
||||
end
|
||||
|
||||
validator = Twilio::Security::RequestValidator.new(channel.auth_token)
|
||||
request_url = reconstruct_url
|
||||
return true if validator.validate(request_url, request.request_parameters, signature)
|
||||
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Signature validation failed ' \
|
||||
"account_sid=#{params[:AccountSid]} channel_id=#{channel.id} url=#{request_url} ip=#{request.remote_ip}"
|
||||
)
|
||||
false
|
||||
end
|
||||
|
||||
def find_twilio_channel
|
||||
if params[:MessagingServiceSid].present?
|
||||
channel = ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
|
||||
if channel.present?
|
||||
return channel if params[:AccountSid].blank? || channel.account_sid == params[:AccountSid]
|
||||
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return if params[:AccountSid].blank?
|
||||
|
||||
find_channel_by_phone_number
|
||||
end
|
||||
|
||||
def find_channel_by_phone_number
|
||||
channel_lookup_phone_numbers.each do |phone|
|
||||
channel = ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: phone)
|
||||
return channel if channel
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def channel_lookup_phone_numbers
|
||||
[params[:To], params[:From]].compact_blank
|
||||
end
|
||||
|
||||
def reconstruct_url
|
||||
url = request.original_url
|
||||
url = url.sub('http://', 'https://') if url.start_with?('http://') && https_request?
|
||||
url
|
||||
end
|
||||
|
||||
def https_request?
|
||||
return true if request.ssl?
|
||||
|
||||
forwarded_proto = request.headers['X-Forwarded-Proto'].to_s.split(',').map(&:strip).find(&:present?)
|
||||
forwarded_proto&.casecmp?('https')
|
||||
end
|
||||
end
|
||||
@@ -80,17 +80,10 @@ class DashboardController < ActionController::Base
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH,
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods,
|
||||
ACTIVE_PLATFORM_BANNERS: active_platform_banners
|
||||
ALLOWED_LOGIN_METHODS: allowed_login_methods
|
||||
}
|
||||
end
|
||||
|
||||
def active_platform_banners
|
||||
return [] unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
|
||||
end
|
||||
|
||||
def allowed_login_methods
|
||||
methods = ['email']
|
||||
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
|
||||
|
||||
@@ -3,7 +3,7 @@ class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
before_action :validate_platform_app_permissible, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').includes(:permissible)
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
|
||||
before_action :ensure_chatwoot_cloud
|
||||
|
||||
private
|
||||
|
||||
def ensure_chatwoot_cloud
|
||||
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Twilio::CallbackController < ApplicationController
|
||||
include TwilioSignatureVerifyConcern
|
||||
|
||||
def create
|
||||
Webhooks::TwilioEventsJob.perform_later(permitted_params.to_unsafe_hash)
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Twilio::DeliveryStatusController < ApplicationController
|
||||
include TwilioSignatureVerifyConcern
|
||||
|
||||
def create
|
||||
Webhooks::TwilioDeliveryStatusJob.perform_later(permitted_params.to_unsafe_hash)
|
||||
|
||||
@@ -18,4 +20,8 @@ class Twilio::DeliveryStatusController < ApplicationController
|
||||
:ErrorMessage
|
||||
)
|
||||
end
|
||||
|
||||
def channel_lookup_phone_numbers
|
||||
[params[:From]].compact_blank
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
require 'administrate/base_dashboard'
|
||||
|
||||
class PlatformBannerDashboard < Administrate::BaseDashboard
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
banner_message: Field::Text.with_options(truncate: 200),
|
||||
banner_type: Field::Select.with_options(collection: %w[info warning error]),
|
||||
active: Field::Boolean,
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime
|
||||
}.freeze
|
||||
|
||||
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
|
||||
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
|
||||
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
|
||||
|
||||
def display_resource(platform_banner)
|
||||
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
|
||||
end
|
||||
end
|
||||
@@ -7,15 +7,11 @@ class UserDrop < BaseDrop
|
||||
@obj.try(:available_name)
|
||||
end
|
||||
|
||||
def email
|
||||
@obj.try(:email)
|
||||
end
|
||||
|
||||
def first_name
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
|
||||
def last_name
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1
|
||||
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,5 +48,3 @@ class MessageFinder
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
end
|
||||
|
||||
MessageFinder.prepend_mod_with('MessageFinder')
|
||||
|
||||
@@ -7,7 +7,7 @@ module EmailHelper
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message(hardbreaks: true).to_s
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import StatusBanner from './components/app/StatusBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
@@ -28,7 +27,6 @@ export default {
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
StatusBanner,
|
||||
PaymentPendingBanner,
|
||||
WootSnackbarBox,
|
||||
PendingEmailVerificationBanner,
|
||||
@@ -61,6 +59,7 @@ export default {
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
@@ -108,9 +107,8 @@ export default {
|
||||
this.$store.dispatch('setActiveAccount', {
|
||||
accountId: this.currentAccountId,
|
||||
});
|
||||
const account = this.getAccount(this.currentAccountId);
|
||||
const { locale, latest_chatwoot_version: latestChatwootVersion } =
|
||||
account;
|
||||
this.getAccount(this.currentAccountId);
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
// If user locale is set, use it; otherwise use account locale
|
||||
this.setLocale(this.uiSettings?.locale || locale);
|
||||
@@ -133,13 +131,12 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching"
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<StatusBanner />
|
||||
<template v-if="currentAccountId">
|
||||
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
|
||||
<PaymentPendingBanner v-if="hideOnOnboardingView" />
|
||||
|
||||
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async joinClientCall({ to, conversationId, callSid }) {
|
||||
async joinClientCall({ to, conversationId }) {
|
||||
if (!this.device || !this.initialized || !to) return null;
|
||||
if (this.activeConnection) return this.activeConnection;
|
||||
|
||||
@@ -76,7 +76,6 @@ class TwilioVoiceClient extends EventTarget {
|
||||
To: to,
|
||||
is_agent: 'true',
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
};
|
||||
|
||||
const connection = await this.device.connect({ params });
|
||||
|
||||
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
leaveConference({ inboxId, conversationId, callSid }) {
|
||||
leaveConference(inboxId, conversationId) {
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
params: { conversation_id: conversationId, call_sid: callSid },
|
||||
params: { conversation_id: conversationId },
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
@@ -72,27 +72,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
category_slug: categorySlug,
|
||||
});
|
||||
}
|
||||
|
||||
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
|
||||
return axios.post(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
|
||||
{ ids: articleIds, locale, category_id: categoryId, force }
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateStatus({ portalSlug, articleIds, status }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
|
||||
{ ids: articleIds, status }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
{ data: { ids: articleIds } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
|
||||
@@ -14,9 +14,9 @@ class MfaAPI extends ApiClient {
|
||||
return axios.post(`${this.url}/verify`, { otp_code: otpCode });
|
||||
}
|
||||
|
||||
disable(password, { otpCode, backupCode } = {}) {
|
||||
disable(password, otpCode) {
|
||||
return axios.delete(this.url, {
|
||||
data: { password, otp_code: otpCode, backup_code: backupCode },
|
||||
data: { password, otp_code: otpCode },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
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';
|
||||
@@ -34,7 +34,9 @@ const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(isVoiceCallEnabled)
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
)
|
||||
);
|
||||
const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
|
||||
|
||||
+4
-10
@@ -35,16 +35,10 @@ const emit = defineEmits([
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
|
||||
@@ -9,15 +9,11 @@ import {
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -48,46 +44,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'openArticle',
|
||||
'articleAction',
|
||||
'toggleSelect',
|
||||
'hover',
|
||||
]);
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||
(acc, [key, item]) => {
|
||||
@@ -100,9 +64,7 @@ const articleMenuItems = computed(() => {
|
||||
const statusItems = (
|
||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||
)
|
||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||
.map(key => commonItems[key]);
|
||||
).map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
});
|
||||
@@ -161,27 +123,14 @@ const handleClick = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
|
||||
</div>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
:autofocus="isNewArticle"
|
||||
autofocus
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="!isNewArticle"
|
||||
:autofocus="false"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
|
||||
+2
-31
@@ -20,14 +20,8 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedArticleIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const router = useRouter();
|
||||
@@ -36,26 +30,12 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
// Enable dragging only for category articles and when there's more than one article
|
||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = id => {
|
||||
return hoveredArticleId.value === id || hasBulkSelection.value;
|
||||
};
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredArticleId.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const openArticle = id => {
|
||||
@@ -172,10 +152,6 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
};
|
||||
|
||||
const updateArticle = ({ action, value, id }) => {
|
||||
if (action === 'translate') {
|
||||
emit('translateArticle', id);
|
||||
return;
|
||||
}
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
@@ -211,14 +187,9 @@ watch(
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
+7
-231
@@ -1,13 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
@@ -15,10 +11,6 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -47,7 +39,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -55,42 +47,6 @@ const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isFetching = useMapGetter('articles/isFetching');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
|
||||
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!visibleArticleIds.value.length) return '';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
|
||||
count: visibleArticleIds.value.length,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
|
||||
count: selectedArticleIds.value.size,
|
||||
})
|
||||
);
|
||||
|
||||
const bulkTranslateDialogRef = ref(null);
|
||||
|
||||
const hasNoArticles = computed(
|
||||
() => !isFetching.value && !props.articles.length
|
||||
@@ -172,80 +128,6 @@ const navigateToNewArticlePage = () => {
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelect = articleId => {
|
||||
const newSet = new Set(selectedArticleIds.value);
|
||||
if (newSet.has(articleId)) {
|
||||
newSet.delete(articleId);
|
||||
} else {
|
||||
newSet.add(articleId);
|
||||
}
|
||||
selectedArticleIds.value = newSet;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedArticleIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleTranslateArticle = articleId => {
|
||||
selectedArticleIds.value = new Set([articleId]);
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const openTranslateDialog = () => {
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const onBulkActionSuccess = message => {
|
||||
useAlert(message);
|
||||
clearSelection();
|
||||
emit('refreshArticles');
|
||||
};
|
||||
|
||||
const bulkUpdateStatus = async status => {
|
||||
try {
|
||||
await articlesAPI.bulkUpdateStatus({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
status,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const bulkDelete = async () => {
|
||||
try {
|
||||
await articlesAPI.bulkDelete({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
});
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selection when articles change (page change, filter change)
|
||||
watch(
|
||||
() => props.articles,
|
||||
() => clearSelection()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -284,91 +166,11 @@ watch(
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<template v-else-if="!hasNoArticles">
|
||||
<div
|
||||
v-if="selectedArticleIds.size > 0"
|
||||
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectedArticleIds"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
|
||||
"
|
||||
class="!px-1.5"
|
||||
@click="clearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-check"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('published')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-pencil-line"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('draft')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-archive-restore"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-languages"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="openTranslateDialog"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
@click="confirmBulkDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</div>
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
/>
|
||||
</template>
|
||||
<ArticleList
|
||||
v-else-if="!hasNoArticles"
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
/>
|
||||
<ArticleEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
@@ -381,31 +183,5 @@ watch(
|
||||
@click="navigateToNewArticlePage"
|
||||
/>
|
||||
</template>
|
||||
<BulkTranslateDialog
|
||||
ref="bulkTranslateDialogRef"
|
||||
:selected-article-ids="[...selectedArticleIds]"
|
||||
:allowed-locales="allowedLocales"
|
||||
@translate-started="clearSelection"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConfirmDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
|
||||
"
|
||||
@confirm="bulkDelete"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedArticleIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateStarted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSubmitting = ref(false);
|
||||
const selectedLocale = ref('');
|
||||
const selectedCategoryId = ref('');
|
||||
const targetCategories = ref([]);
|
||||
const isFetchingCategories = ref(false);
|
||||
const duplicateArticles = ref([]);
|
||||
|
||||
const currentLocale = computed(() => route.params.locale);
|
||||
|
||||
const localeOptions = computed(() => {
|
||||
return props.allowedLocales
|
||||
.filter(locale => locale.code !== currentLocale.value)
|
||||
.map(locale => ({
|
||||
value: locale.code,
|
||||
label: `${locale.name} (${locale.code})`,
|
||||
}));
|
||||
});
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
return targetCategories.value.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
}));
|
||||
});
|
||||
|
||||
const articleCount = computed(() => props.selectedArticleIds.length);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
|
||||
);
|
||||
|
||||
const description = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
|
||||
);
|
||||
|
||||
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (hasDuplicates.value) {
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
|
||||
}
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
|
||||
});
|
||||
|
||||
const isConfirmDisabled = computed(() => {
|
||||
return !selectedLocale.value || isSubmitting.value;
|
||||
});
|
||||
|
||||
const articleEditUrl = articleId => {
|
||||
const { portalSlug, categorySlug, tab } = route.params;
|
||||
const resolved = router.resolve({
|
||||
name: 'portals_articles_edit',
|
||||
params: {
|
||||
portalSlug,
|
||||
locale: selectedLocale.value,
|
||||
categorySlug,
|
||||
tab,
|
||||
articleSlug: articleId,
|
||||
},
|
||||
});
|
||||
return resolved.href;
|
||||
};
|
||||
|
||||
const fetchCategoriesForLocale = async locale => {
|
||||
if (!locale) {
|
||||
targetCategories.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isFetchingCategories.value = true;
|
||||
try {
|
||||
const { data } = await categoriesAPI.get({
|
||||
portalSlug: route.params.portalSlug,
|
||||
locale,
|
||||
});
|
||||
targetCategories.value = data.payload;
|
||||
} catch {
|
||||
targetCategories.value = [];
|
||||
} finally {
|
||||
isFetchingCategories.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedLocale, newLocale => {
|
||||
selectedCategoryId.value = '';
|
||||
duplicateArticles.value = [];
|
||||
fetchCategoriesForLocale(newLocale);
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
selectedCategoryId.value = '';
|
||||
targetCategories.value = [];
|
||||
duplicateArticles.value = [];
|
||||
};
|
||||
|
||||
const submitTranslation = async (force = false) => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/bulkTranslate', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: props.selectedArticleIds,
|
||||
locale: selectedLocale.value,
|
||||
categoryId: selectedCategoryId.value,
|
||||
force,
|
||||
});
|
||||
|
||||
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
emit('translateStarted');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 409) {
|
||||
duplicateArticles.value = error.response.data.duplicate_articles;
|
||||
return;
|
||||
}
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
|
||||
);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (isConfirmDisabled.value) return;
|
||||
submitTranslation(hasDuplicates.value);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="description"
|
||||
:confirm-button-label="confirmLabel"
|
||||
:disable-confirm-button="isConfirmDisabled"
|
||||
:is-loading="isSubmitting"
|
||||
@close="resetForm"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
|
||||
<span class="text-n-slate-10 font-normal">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
|
||||
</span>
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedCategoryId"
|
||||
:options="categoryOptions"
|
||||
:disabled="!selectedLocale || isFetchingCategories"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasDuplicates"
|
||||
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="text-sm text-n-amber-12 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
|
||||
duplicateArticles.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a
|
||||
v-for="article in duplicateArticles"
|
||||
:key="article.id"
|
||||
:href="articleEditUrl(article.id)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
|
||||
>
|
||||
{{ article.title }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-xs text-n-amber-11 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -233,7 +233,6 @@ onMounted(() => resetContacts());
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
|
||||
+7
-4
@@ -8,6 +8,8 @@ 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';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
@@ -18,7 +20,6 @@ const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -28,7 +29,6 @@ const props = defineProps({
|
||||
isDropdownActive: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
inboxId: { type: Number, default: null },
|
||||
voiceEnabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -81,9 +81,11 @@ const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const isVoiceInbox = computed(() => props.channelType === INBOX_TYPES.VOICE);
|
||||
|
||||
const shouldShowSignatureButton = computed(() => {
|
||||
return (
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !props.voiceEnabled
|
||||
props.hasSelectedInbox && isRegularMessageMode.value && !isVoiceInbox.value
|
||||
);
|
||||
});
|
||||
|
||||
@@ -108,7 +110,7 @@ watch(
|
||||
() => props.hasSelectedInbox,
|
||||
newValue => {
|
||||
nextTick(() => {
|
||||
if (newValue && !props.voiceEnabled) setSignature();
|
||||
if (newValue && !isVoiceInbox.value) setSignature();
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -196,6 +198,7 @@ useEventListener(document, 'paste', onPaste);
|
||||
<WhatsAppOptions
|
||||
v-if="isWhatsappInbox"
|
||||
:inbox-id="inboxId"
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
|
||||
+1
-4
@@ -2,7 +2,7 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { INBOX_TYPES, isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
@@ -100,8 +100,6 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
|
||||
const inboxMedium = computed(() => props.targetInbox?.medium || '');
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(props.targetInbox));
|
||||
|
||||
const effectiveChannelType = computed(() =>
|
||||
getEffectiveChannelType(inboxChannelType.value, inboxMedium.value)
|
||||
);
|
||||
@@ -444,7 +442,6 @@ useKeyboardEvents({
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:voice-enabled="voiceCallEnabled"
|
||||
:is-loading="isCreating"
|
||||
:disable-send-button="isCreating"
|
||||
:has-selected-inbox="!!targetInbox"
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ const handleBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
|
||||
+57
-67
@@ -6,7 +6,6 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -23,6 +22,7 @@ const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
@@ -39,36 +39,29 @@ const filteredTemplates = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const handlePopoverShow = () => {
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handlePopoverHide = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = (template, hide) => {
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
hide();
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
align="start"
|
||||
disable-mobile-view
|
||||
@show="handlePopoverShow"
|
||||
@hide="handlePopoverHide"
|
||||
>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
@@ -76,59 +69,56 @@ const handleSendMessage = (template, hide) => {
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-if="!selectedTemplate"
|
||||
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-else
|
||||
:template="selectedTemplate"
|
||||
@send-message="payload => handleSendMessage(payload, hide)"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+53
-62
@@ -5,7 +5,6 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -25,6 +24,8 @@ const getFilteredWhatsAppTemplates = useMapGetter(
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const whatsAppTemplateMessages = computed(() => {
|
||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||
});
|
||||
@@ -39,36 +40,29 @@ const getTemplateBody = template => {
|
||||
return template.components.find(component => component.type === 'BODY').text;
|
||||
};
|
||||
|
||||
const handlePopoverShow = () => {
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handlePopoverHide = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = (template, hide) => {
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
hide();
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover
|
||||
align="start"
|
||||
disable-mobile-view
|
||||
@show="handlePopoverShow"
|
||||
@hide="handlePopoverHide"
|
||||
>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ri-whatsapp-line"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
|
||||
@@ -76,53 +70,50 @@ const handleSendMessage = (template, hide) => {
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-if="!selectedTemplate"
|
||||
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ getTemplateBody(template) }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
v-else
|
||||
:template="selectedTemplate"
|
||||
@send-message="payload => handleSendMessage(payload, hide)"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ getTemplateBody(template) }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ const handleBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<WhatsAppTemplateParser
|
||||
:template="template"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
@@ -18,13 +20,11 @@ const emit = defineEmits(['action']);
|
||||
|
||||
const bannerClass = computed(() => {
|
||||
const classMap = {
|
||||
slate:
|
||||
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11',
|
||||
amber:
|
||||
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
|
||||
slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
|
||||
amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
|
||||
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
|
||||
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
|
||||
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
|
||||
};
|
||||
|
||||
return classMap[props.color];
|
||||
|
||||
@@ -135,16 +135,6 @@ export function useContactFilterContext() {
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
value: CONTACT_ATTRIBUTES.COMPANY_NAME,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.COMPANY'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
value: CONTACT_ATTRIBUTES.CREATED_AT,
|
||||
|
||||
@@ -23,7 +23,6 @@ export const CONTACT_ATTRIBUTES = {
|
||||
IDENTIFIER: 'identifier',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
CITY: 'city',
|
||||
COMPANY_NAME: 'company_name',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
REFERER: 'referer',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { computed } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
|
||||
export function useChannelIcon(inbox) {
|
||||
const channelTypeIconMap = {
|
||||
@@ -15,6 +14,7 @@ export function useChannelIcon(inbox) {
|
||||
'Channel::Whatsapp': 'i-woot-whatsapp',
|
||||
'Channel::Instagram': 'i-woot-instagram',
|
||||
'Channel::Tiktok': 'i-woot-tiktok',
|
||||
'Channel::Voice': 'i-woot-voice',
|
||||
};
|
||||
|
||||
const providerIconMap = {
|
||||
@@ -38,11 +38,6 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Special case for voice-enabled inboxes (Twilio, WhatsApp, etc.)
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,8 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
it('returns correct icon for Voice channel', () => {
|
||||
const inbox = { channel_type: 'Channel::Voice' };
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
@@ -30,10 +30,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
focusOnMount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -86,7 +82,6 @@ onMounted(() => {
|
||||
|
||||
defineExpose({
|
||||
focus: () => inlineInputRef.value?.focus(),
|
||||
blur: () => inlineInputRef.value?.blur(),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -111,7 +106,6 @@ defineExpose({
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:class="customInputClass"
|
||||
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none outline-none outline-0 bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
|
||||
@input="handleInput"
|
||||
|
||||
@@ -113,7 +113,6 @@ const props = defineProps({
|
||||
validator: value => Object.values(MESSAGE_STATUS).includes(value),
|
||||
},
|
||||
attachments: { type: Array, default: () => [] },
|
||||
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
|
||||
content: { type: String, default: null },
|
||||
contentAttributes: { type: Object, default: () => ({}) },
|
||||
contentType: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
|
||||
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+6
-6
@@ -10,22 +10,22 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" />
|
||||
<div class="flex flex-col gap-2 pt-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm prose prose-bubble"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center items-center p-3">
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="flex justify-center items-center p-3">
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
-1
@@ -8,7 +8,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80">
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { VOICE_CALL_STATUS } from '../constants';
|
||||
import { useCallSession } from 'dashboard/composables/useCallSession';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
@@ -14,6 +11,11 @@ const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
@@ -28,41 +30,15 @@ const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
|
||||
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
|
||||
useCallSession();
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
|
||||
const status = computed(() => call.value?.status);
|
||||
const isOutbound = computed(() => call.value?.direction === 'outgoing');
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
|
||||
const didCurrentUserAnswer = computed(
|
||||
() =>
|
||||
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
|
||||
);
|
||||
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
|
||||
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
|
||||
// Twilio's call-status webhook flipped the call to in-progress before the
|
||||
// participant-join webhook claimed it).
|
||||
const conversationAssignee = computed(() => {
|
||||
const conversation = store.getters.getConversationById?.(
|
||||
conversationId?.value
|
||||
);
|
||||
return conversation?.meta?.assignee || null;
|
||||
});
|
||||
const displayAgentName = computed(() => {
|
||||
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
|
||||
if (acceptedByAgentId.value) {
|
||||
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
|
||||
if (agent?.available_name) return agent.available_name;
|
||||
if (agent?.name) return agent.name;
|
||||
}
|
||||
return conversationAssignee.value?.name || null;
|
||||
});
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
@@ -76,28 +52,16 @@ const labelKey = computed(() => {
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtext = computed(() => {
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
}
|
||||
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
|
||||
return t('CONVERSATION.VOICE_CALL.CALL_ENDED');
|
||||
}
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
if (didCurrentUserAnswer.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED');
|
||||
}
|
||||
if (displayAgentName.value) {
|
||||
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
|
||||
agentName: displayAgentName.value,
|
||||
});
|
||||
}
|
||||
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
return isFailed.value
|
||||
? t('CONVERSATION.VOICE_CALL.NO_ANSWER')
|
||||
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
@@ -106,45 +70,6 @@ const iconName = computed(() => {
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
|
||||
const callSid = computed(() => call.value?.providerCallId);
|
||||
|
||||
// Show "Join call" when the call is still ringing, no agent has claimed it,
|
||||
// and the conversation is unassigned or assigned to the current user. Mirrors
|
||||
// the eligibility used by FloatingCallWidget so the bubble can act as a
|
||||
// recovery affordance after a refresh or missed widget.
|
||||
const canJoinCall = computed(() => {
|
||||
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
|
||||
if (isOutbound.value) return false;
|
||||
if (acceptedByAgentId.value) return false;
|
||||
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
|
||||
// Suppress the button once this call is the local active session — the
|
||||
// message status webhook may lag behind, so we can't rely on `status` alone
|
||||
// to hide it after a successful join from this client.
|
||||
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
|
||||
return false;
|
||||
const assignee = conversationAssignee.value;
|
||||
if (assignee?.id && assignee.id !== currentUserId.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleJoinCall = async () => {
|
||||
if (!canJoinCall.value || isJoining.value) return;
|
||||
|
||||
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
|
||||
await endCall({
|
||||
conversationId: activeCall.value.conversationId,
|
||||
inboxId: activeCall.value.inboxId,
|
||||
callSid: activeCall.value.callSid,
|
||||
});
|
||||
}
|
||||
|
||||
await joinCall({
|
||||
conversationId: conversationId.value,
|
||||
inboxId: inboxId.value,
|
||||
callSid: callSid.value,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -170,17 +95,8 @@ const handleJoinCall = async () => {
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ subtext }}
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
<button
|
||||
v-if="canJoinCall"
|
||||
type="button"
|
||||
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
|
||||
:disabled="isJoining"
|
||||
@click="handleJoinCall"
|
||||
>
|
||||
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -12,14 +12,6 @@ const props = defineProps({
|
||||
default: 'end',
|
||||
validator: v => ['start', 'end'].includes(v),
|
||||
},
|
||||
disableMobileView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['show', 'hide']);
|
||||
@@ -30,8 +22,7 @@ const popoverRef = ref(null);
|
||||
const mobileContentRef = ref(null);
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const belowMd = breakpoints.smaller('md');
|
||||
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
|
||||
const isMobile = breakpoints.smaller('md');
|
||||
const showPopover = computed(() => isActive.value && !isMobile.value);
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
@@ -108,13 +99,9 @@ defineExpose({ show, hide, toggle });
|
||||
{ ignore: clickOutsideIgnore },
|
||||
]"
|
||||
data-popover-content
|
||||
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
class="relative w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 overflow-y-auto bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -126,14 +113,9 @@ defineExpose({ show, hide, toggle });
|
||||
data-popover-content
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl overflow-y-auto max-h-[calc(100vh-2rem)]"
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||
:class="{ 'border border-n-strong': showContentBorder }"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
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
|
||||
v-if="message.buttons && message.buttons.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
v-for="(button, index) in message.buttons"
|
||||
:key="index"
|
||||
:label="button.text || button.title || 'Button'"
|
||||
slate
|
||||
class="!text-n-blue-11 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
|
||||
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
|
||||
const DOC_EXTENSIONS = ['.doc'];
|
||||
|
||||
const mediaType = computed(() => {
|
||||
if (props.message.mediaType) return props.message.mediaType;
|
||||
|
||||
const format = props.message.header?.format;
|
||||
if (format) return format.toLowerCase();
|
||||
|
||||
const url = props.message.image_url || '';
|
||||
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
|
||||
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
|
||||
return 'image';
|
||||
});
|
||||
|
||||
const fileType = computed(() => {
|
||||
const url = props.message.image_url || '';
|
||||
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44 rounded-lg"
|
||||
alt="Template media"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="mediaType === 'video'"
|
||||
class="overflow-hidden relative rounded-lg"
|
||||
>
|
||||
<video
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mediaType === 'document'" class="flex items-center">
|
||||
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
|
||||
</div>
|
||||
|
||||
<span
|
||||
v-if="message.content"
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const actions = computed(() => props.message.actions || []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(action, index) in actions"
|
||||
:key="index"
|
||||
class="flex justify-center items-center p-3"
|
||||
>
|
||||
<Button
|
||||
:label="action.title || action.text || 'Button'"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,105 +0,0 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twilio-templates.js';
|
||||
|
||||
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
|
||||
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
|
||||
|
||||
const greet = findWhatsApp('greet');
|
||||
const eventInvitation = findWhatsApp('event_invitation_static');
|
||||
const orderConfirmation = findWhatsApp('order_confirmation');
|
||||
const discountCoupon = findWhatsApp('discount_coupon');
|
||||
const trainingVideo = findWhatsApp('training_video');
|
||||
|
||||
const twilioGreet = findTwilio('greet');
|
||||
const shoeLaunch = findTwilio('shoe_launch');
|
||||
const welcomeMessage = findTwilio('welcome_message_new');
|
||||
const courseFeeReminder = findTwilio('course_fee_reminder');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreview"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<Variant title="WhatsApp - Simple Text">
|
||||
<TemplatePreview
|
||||
:template="greet"
|
||||
:variables="getWhatsAppVariables(greet)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Simple Text (No Variables)">
|
||||
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Call to Action Buttons">
|
||||
<TemplatePreview
|
||||
:template="eventInvitation"
|
||||
:variables="getWhatsAppVariables(eventInvitation)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Image Media">
|
||||
<TemplatePreview
|
||||
:template="orderConfirmation"
|
||||
:variables="getWhatsAppVariables(orderConfirmation)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Video Media">
|
||||
<TemplatePreview
|
||||
:template="trainingVideo"
|
||||
:variables="getWhatsAppVariables(trainingVideo)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Copy Code">
|
||||
<TemplatePreview
|
||||
:template="discountCoupon"
|
||||
:variables="getWhatsAppVariables(discountCoupon)"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Text">
|
||||
<TemplatePreview
|
||||
:template="twilioGreet"
|
||||
:variables="twilioGreet.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Media">
|
||||
<TemplatePreview
|
||||
:template="shoeLaunch"
|
||||
:variables="shoeLaunch.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Quick Reply">
|
||||
<TemplatePreview
|
||||
:template="welcomeMessage"
|
||||
:variables="welcomeMessage.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Call to Action">
|
||||
<TemplatePreview
|
||||
:template="courseFeeReminder"
|
||||
:variables="courseFeeReminder.variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,112 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
import {
|
||||
PLATFORMS,
|
||||
TEMPLATE_TYPES,
|
||||
WA_HEADER_FORMATS,
|
||||
WA_MEDIA_FORMATS,
|
||||
} from 'dashboard/services/TemplateConstants';
|
||||
|
||||
import CardTemplate from './CardTemplate.vue';
|
||||
import CallToActionTemplate from './CallToActionTemplate.vue';
|
||||
import MediaTemplate from './MediaTemplate.vue';
|
||||
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
|
||||
import QuickReplyTemplate from './QuickReplyTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
variables: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => Object.values(PLATFORMS).includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const COMPONENT_MAP = {
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
|
||||
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
|
||||
};
|
||||
|
||||
const substituteVariables = (text, variables) => {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
|
||||
const value = variables[variable];
|
||||
return value !== undefined && value !== '' ? value : `[${variable}]`;
|
||||
});
|
||||
};
|
||||
|
||||
const processedTemplate = computed(() => {
|
||||
const normalized = TemplateNormalizer.normalize(
|
||||
props.template,
|
||||
props.platform
|
||||
);
|
||||
|
||||
let content = '';
|
||||
let imageUrl = '';
|
||||
let title = '';
|
||||
let footer = '';
|
||||
|
||||
if (props.platform === PLATFORMS.WHATSAPP) {
|
||||
content = normalized.body?.text || '';
|
||||
|
||||
if (normalized.header) {
|
||||
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
|
||||
imageUrl = normalized.header.example?.header_handle?.[0] || '';
|
||||
}
|
||||
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
|
||||
title = normalized.header.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
footer = normalized.footer?.text || '';
|
||||
} else {
|
||||
content = normalized.body || '';
|
||||
|
||||
if (normalized.media && normalized.media.length > 0) {
|
||||
imageUrl = normalized.media[0];
|
||||
}
|
||||
}
|
||||
|
||||
const buttons = normalized.buttons?.length
|
||||
? normalized.buttons
|
||||
: normalized.actions || [];
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: substituteVariables(content, props.variables),
|
||||
title: substituteVariables(title, props.variables),
|
||||
footer: substituteVariables(footer, props.variables),
|
||||
image_url: substituteVariables(imageUrl, props.variables),
|
||||
buttons,
|
||||
actions: normalized.actions || [],
|
||||
};
|
||||
});
|
||||
|
||||
const previewComponent = computed(
|
||||
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-preview">
|
||||
<component :is="previewComponent" :message="processedTemplate" />
|
||||
</div>
|
||||
</template>
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twilio-templates.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreviewExamples"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<Variant title="WA: Event Invitation (Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[0]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Purchase Receipt (Document)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[1]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Discount Coupon (Copy Code)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[2]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Support Callback (Phone)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[3]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Training Video (Video)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[4]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Order Confirmation (Image)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[5]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Product Launch (Image + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[6]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Technician Visit (Header + Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[7]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Greet (Simple Text)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[8]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Hello World (Header + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[9]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Feedback Request (Button)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[10]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Address Update (Header)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[11]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Delivery Confirmation">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[12]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Shoe Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[0]"
|
||||
:variables="twilioTemplates[0].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch Custom Price (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[1]"
|
||||
:variables="twilioTemplates[1].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[2]"
|
||||
:variables="twilioTemplates[2].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Greet (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[3]"
|
||||
:variables="twilioTemplates[3].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Order Status (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[4]"
|
||||
:variables="twilioTemplates[4].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Hello World (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[5]"
|
||||
:variables="twilioTemplates[5].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message New (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[6]"
|
||||
:variables="twilioTemplates[6].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[7]"
|
||||
:variables="twilioTemplates[7].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[8]"
|
||||
:variables="twilioTemplates[8].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[9]"
|
||||
:variables="twilioTemplates[9].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<div v-if="message.title" class="text-base font-bold">
|
||||
{{ message.title }}
|
||||
</div>
|
||||
|
||||
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
|
||||
<span v-dompurify-html="message.content" />
|
||||
</div>
|
||||
|
||||
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
|
||||
{{ message.footer }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
// Main Template Preview Component
|
||||
export { default as TemplatePreview } from './TemplatePreview.vue';
|
||||
|
||||
// Core Services
|
||||
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
|
||||
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
-209
@@ -1,209 +0,0 @@
|
||||
export const twilioTemplates = [
|
||||
{
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '400$',
|
||||
},
|
||||
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
|
||||
friendly_name: 'product_launch_custom_price',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
|
||||
friendly_name: 'product_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
3: '12345',
|
||||
},
|
||||
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
|
||||
friendly_name: 'order_status',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
|
||||
friendly_name: 'hello_world',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
actions: [
|
||||
{
|
||||
id: 'Chatwoot Cloud_payload',
|
||||
title: 'Chatwoot Cloud',
|
||||
},
|
||||
{
|
||||
id: 'Self-hosted Chatwoot_payload',
|
||||
title: 'Self-hosted Chatwoot',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
|
||||
friendly_name: 'saas_whatsapp_question',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Demo_payload',
|
||||
title: 'Demo',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
|
||||
friendly_name: 'welcome_message',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
types: {
|
||||
'twilio/call-to-action': {
|
||||
actions: [
|
||||
{
|
||||
id: null,
|
||||
title: 'Pay now',
|
||||
type: 'URL',
|
||||
url: 'https://payments.example.com/pay',
|
||||
},
|
||||
],
|
||||
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
date: '01-Jan-2026',
|
||||
},
|
||||
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
|
||||
friendly_name: 'course_fee_reminder',
|
||||
template_type: 'call_to_action',
|
||||
},
|
||||
];
|
||||
-408
@@ -1,408 +0,0 @@
|
||||
export const whatsAppTemplates = [
|
||||
{
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '767076159336759',
|
||||
name: 'purchase_receipt',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'DOCUMENT',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['credit', 'CS Mutual', 'receipt']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE1OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1075221534579807',
|
||||
name: 'support_callback',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: '232323',
|
||||
param_name: 'ticket_id',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+2112121212',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1242180011253003',
|
||||
name: 'product_launch',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'blue',
|
||||
param_name: 'color',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1449876326175680',
|
||||
name: 'technician_visit',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Technician visit',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [
|
||||
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Confirm',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
{
|
||||
text: 'Reschedule',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '632315222954611',
|
||||
name: 'hello_world',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello World',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'WhatsApp Business Platform sample message',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '787864066907971',
|
||||
name: 'feedback_request',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://feedback.example.com/survey',
|
||||
text: 'Leave Feedback',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1938057163677205',
|
||||
name: 'address_update',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Address update',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', '123 Main St', 'support@telco.com']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1644094842949394',
|
||||
name: 'delivery_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', 'Jan 1, 2024']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get variable values from examples
|
||||
export const getWhatsAppVariables = template => {
|
||||
const variables = {};
|
||||
template.components?.forEach(component => {
|
||||
if (component.example?.body_text_named_params) {
|
||||
component.example.body_text_named_params.forEach(param => {
|
||||
variables[param.param_name] = param.example;
|
||||
});
|
||||
}
|
||||
if (component.example?.body_text) {
|
||||
component.example.body_text[0]?.forEach((value, index) => {
|
||||
variables[(index + 1).toString()] = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
@@ -1,63 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
|
||||
const BANNER_COLOR_MAP = {
|
||||
info: 'blue',
|
||||
warning: 'amber',
|
||||
error: 'ruby',
|
||||
};
|
||||
|
||||
const { t } = useI18n();
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const dismissedBannerIds = ref(
|
||||
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
|
||||
);
|
||||
|
||||
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
|
||||
|
||||
const visibleBanners = computed(() => {
|
||||
const banners = globalConfig.value?.activePlatformBanners || [];
|
||||
return banners.filter(
|
||||
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
|
||||
);
|
||||
});
|
||||
|
||||
const formattedMessage = message =>
|
||||
new MessageFormatter(message).formattedMessage;
|
||||
|
||||
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
|
||||
|
||||
const dismissBanner = banner => {
|
||||
const key = dismissKey(banner);
|
||||
if (dismissedBannerIds.value.includes(key)) return;
|
||||
|
||||
dismissedBannerIds.value.push(key);
|
||||
LocalStorage.set(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
|
||||
dismissedBannerIds.value
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-for="banner in visibleBanners"
|
||||
:key="banner.id"
|
||||
:color="bannerColor(banner.banner_type)"
|
||||
:action-label="t('GENERAL_SETTINGS.DISMISS')"
|
||||
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
|
||||
@action="dismissBanner(banner)"
|
||||
>
|
||||
<span
|
||||
v-dompurify-html="formattedMessage(banner.banner_message)"
|
||||
class="text-xs"
|
||||
/>
|
||||
</Banner>
|
||||
</template>
|
||||
@@ -1,5 +1,3 @@
|
||||
<!-- DEPRECIATED -->
|
||||
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
|
||||
<script>
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ const handleEndCall = async () => {
|
||||
await endCallSession({
|
||||
conversationId: call.conversationId,
|
||||
inboxId,
|
||||
callSid: call.callSid,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
collapseSelection,
|
||||
findNodeToInsertImage,
|
||||
getContentNode,
|
||||
insertAtCursor,
|
||||
@@ -67,7 +66,6 @@ import {
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
hasPressedCommandAndEnter,
|
||||
isEscape,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
@@ -517,9 +515,7 @@ function setMenubarPosition({ selection } = {}) {
|
||||
|
||||
function checkSelection(editorState) {
|
||||
showSelectionMenu.value = false;
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
const hasSelection = editorState.selection.from !== editorState.selection.to;
|
||||
if (hasSelection === isTextSelected.value) return;
|
||||
|
||||
isTextSelected.value = hasSelection;
|
||||
@@ -715,17 +711,12 @@ function handleLineBreakWhenCmdAndEnterToSendEnabled(event) {
|
||||
}
|
||||
|
||||
function onKeydown(event) {
|
||||
if (isEscape(event)) {
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
if (isEnterToSendEnabled()) {
|
||||
handleLineBreakWhenEnterToSendEnabled(event);
|
||||
}
|
||||
if (isCmdPlusEnterToSendEnabled()) {
|
||||
handleLineBreakWhenCmdAndEnterToSendEnabled(event);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createEditorView() {
|
||||
@@ -753,9 +744,6 @@ function createEditorView() {
|
||||
blur: () => {
|
||||
if (props.disabled) return;
|
||||
typingIndicator.stop();
|
||||
// PM keeps its selection on blur — clear the menu flags manually.
|
||||
isTextSelected.value = false;
|
||||
editorRoot.value?.classList.remove('has-selection');
|
||||
emit('blur');
|
||||
},
|
||||
paste: (view, event) => {
|
||||
|
||||
@@ -17,8 +17,6 @@ import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
import { collapseSelection } from 'dashboard/helper/editorHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
@@ -364,33 +362,19 @@ export default {
|
||||
onKeyup() {
|
||||
this.$emit('keyup');
|
||||
},
|
||||
onKeydown(view, event) {
|
||||
onKeydown() {
|
||||
this.$emit('keydown');
|
||||
if (isEscape(event)) {
|
||||
if (this.showSlashMenu) {
|
||||
this.showSlashMenu = false;
|
||||
this.slashSearchTerm = '';
|
||||
this.slashMenuPosition = null;
|
||||
return true;
|
||||
}
|
||||
collapseSelection(editorView);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onBlur() {
|
||||
// ProseMirror keeps its selection on blur — clear the menu flag manually.
|
||||
this.isTextSelected = false;
|
||||
this.$refs.editor?.classList.remove('has-selection');
|
||||
this.$emit('blur');
|
||||
},
|
||||
onFocus() {
|
||||
this.$emit('focus');
|
||||
},
|
||||
checkSelection(editorState) {
|
||||
const { selection } = editorState;
|
||||
// Skip NodeSelection (from Esc -> selectParentNode); only text ranges count.
|
||||
const hasSelection = !selection.empty && !selection.node;
|
||||
const { from, to } = editorState.selection;
|
||||
// Check if there's a selection (from and to are different)
|
||||
const hasSelection = from !== to;
|
||||
// If the selection state is the same as the previous state, do nothing
|
||||
if (hasSelection === this.isTextSelected) return;
|
||||
// Update the selection state
|
||||
|
||||
@@ -38,16 +38,10 @@ const unreadCount = computed(() => props.chat.unread_count);
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
|
||||
@@ -47,11 +47,7 @@ const mockStore = createStore({
|
||||
11: { id: 11, channel_type: INBOX_TYPES.API },
|
||||
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
||||
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
||||
14: {
|
||||
id: 14,
|
||||
channel_type: INBOX_TYPES.TWILIO,
|
||||
voice_enabled: true,
|
||||
},
|
||||
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
|
||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
@@ -215,11 +211,11 @@ describe('useInbox', () => {
|
||||
});
|
||||
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
||||
|
||||
// Test Voice (Twilio with voice_enabled)
|
||||
// Test Voice
|
||||
wrapper = mount(createTestComponent(14), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.voiceCallEnabled).toBe(true);
|
||||
expect(wrapper.vm.isAVoiceChannel).toBe(true);
|
||||
|
||||
// Test Tiktok
|
||||
wrapper = mount(createTestComponent(15), {
|
||||
@@ -278,8 +274,7 @@ describe('useInbox', () => {
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isATiktokChannel',
|
||||
'voiceCallEnabled',
|
||||
'voiceCallProvider',
|
||||
'isAVoiceChannel',
|
||||
];
|
||||
|
||||
expectedProperties.forEach(prop => {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
|
||||
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Timer from 'dashboard/helper/Timer';
|
||||
|
||||
export function useCallSession() {
|
||||
const callsStore = useCallsStore();
|
||||
const { t } = useI18n();
|
||||
const isJoining = ref(false);
|
||||
const callDuration = ref(0);
|
||||
const durationTimer = new Timer(elapsed => {
|
||||
@@ -45,8 +42,8 @@ export function useCallSession() {
|
||||
);
|
||||
});
|
||||
|
||||
const endCall = async ({ conversationId, inboxId, callSid }) => {
|
||||
await VoiceAPI.leaveConference({ inboxId, conversationId, callSid });
|
||||
const endCall = async ({ conversationId, inboxId }) => {
|
||||
await VoiceAPI.leaveConference(inboxId, conversationId);
|
||||
TwilioVoiceClient.endClientCall();
|
||||
durationTimer.stop();
|
||||
callsStore.clearActiveCall();
|
||||
@@ -69,7 +66,6 @@ export function useCallSession() {
|
||||
await TwilioVoiceClient.joinClientCall({
|
||||
to: joinResponse?.conference_sid,
|
||||
conversationId,
|
||||
callSid,
|
||||
});
|
||||
|
||||
callsStore.setCallActive(callSid);
|
||||
@@ -77,11 +73,6 @@ export function useCallSession() {
|
||||
|
||||
return { conferenceSid: joinResponse?.conference_sid };
|
||||
} catch (error) {
|
||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
if (error?.response?.status === 409) {
|
||||
TwilioVoiceClient.endClientCall();
|
||||
callsStore.dismissCall(callSid);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to join call:', error);
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
isVoiceCallEnabled,
|
||||
getVoiceCallProvider,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
@@ -138,9 +134,9 @@ export const useInbox = (inboxId = null) => {
|
||||
return channelType.value === INBOX_TYPES.TIKTOK;
|
||||
});
|
||||
|
||||
const voiceCallEnabled = computed(() => isVoiceCallEnabled(inbox.value));
|
||||
|
||||
const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value));
|
||||
const isAVoiceChannel = computed(() => {
|
||||
return channelType.value === INBOX_TYPES.VOICE;
|
||||
});
|
||||
|
||||
return {
|
||||
inbox,
|
||||
@@ -160,7 +156,6 @@ export const useInbox = (inboxId = null) => {
|
||||
isAnEmailChannel,
|
||||
isAnInstagramChannel,
|
||||
isATiktokChannel,
|
||||
voiceCallEnabled,
|
||||
voiceCallProvider,
|
||||
isAVoiceChannel,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -109,6 +109,11 @@ export const FORMATTING = {
|
||||
'redo',
|
||||
],
|
||||
},
|
||||
'Channel::Voice': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
menu: [],
|
||||
},
|
||||
'Channel::Tiktok': {
|
||||
marks: [],
|
||||
nodes: [],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export const LOCAL_STORAGE_KEYS = {
|
||||
DISMISSED_UPDATES: 'dismissedUpdates',
|
||||
DISMISSED_PLATFORM_BANNERS: 'dismissedPlatformBanners',
|
||||
WIDGET_BUILDER: 'widgetBubble_',
|
||||
DRAFT_MESSAGES: 'draftMessages',
|
||||
COLOR_SCHEME: 'color_scheme',
|
||||
|
||||
@@ -36,7 +36,6 @@ export const FEATURE_FLAGS = {
|
||||
CHATWOOT_V4: 'chatwoot_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CHANNEL_VOICE: 'channel_voice',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
|
||||
@@ -153,8 +153,3 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
|
||||
NEXT_CLICKED: 'Year in Review: Next clicked',
|
||||
SHARE_CLICKED: 'Year in Review: Share clicked',
|
||||
});
|
||||
|
||||
export const ONBOARDING_EVENTS = Object.freeze({
|
||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||
});
|
||||
|
||||
@@ -33,7 +33,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'account.enrichment_completed': this.onEnrichmentCompleted,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
@@ -195,10 +194,6 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('copilotMessages/upsert', data);
|
||||
};
|
||||
|
||||
onEnrichmentCompleted = () => {
|
||||
this.app.$store.dispatch('accounts/get', { silent: true });
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
const keys = data.cache_keys;
|
||||
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import * as Sentry from '@sentry/vue';
|
||||
@@ -274,18 +273,6 @@ export const scrollCursorIntoView = view => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Collapse the current selection to a cursor near its head. Used to override
|
||||
* the default Escape -> selectParentNode behavior which would otherwise keep
|
||||
* the text highlight visible.
|
||||
*
|
||||
* @param {EditorView} view - The ProseMirror EditorView
|
||||
*/
|
||||
export const collapseSelection = view => {
|
||||
const { tr, selection } = view.state;
|
||||
view.dispatch(tr.setSelection(Selection.near(selection.$head)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a transaction that inserts a node into editor at the given position
|
||||
* Has an optional param 'content' to check if the
|
||||
|
||||
@@ -11,29 +11,9 @@ export const INBOX_TYPES = {
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
TIKTOK: 'Channel::Tiktok',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||
export const VOICE_CALL_PROVIDERS = {
|
||||
TWILIO: 'twilio',
|
||||
};
|
||||
|
||||
export const getVoiceCallProvider = inbox => {
|
||||
if (!inbox) return null;
|
||||
|
||||
// Callers pass either snake_case (raw API) or camelCase (after camelcaseKeys) shapes.
|
||||
const channelType = inbox.channel_type || inbox.channelType;
|
||||
const voiceEnabled = inbox.voice_enabled || inbox.voiceEnabled;
|
||||
|
||||
if (channelType === INBOX_TYPES.TWILIO && voiceEnabled) {
|
||||
return VOICE_CALL_PROVIDERS.TWILIO;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
@@ -50,6 +30,7 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -64,6 +45,7 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
|
||||
[INBOX_TYPES.LINE]: 'i-woot-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
|
||||
[INBOX_TYPES.VOICE]: 'i-woot-voice',
|
||||
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
|
||||
};
|
||||
|
||||
@@ -76,6 +58,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -114,6 +97,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -156,6 +142,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.TIKTOK:
|
||||
return 'brand-tiktok';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
@@ -91,13 +91,6 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
action: 'archive',
|
||||
icon: 'i-lucide-archive-restore',
|
||||
},
|
||||
translate: {
|
||||
label:
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
|
||||
value: 'translate',
|
||||
action: 'translate',
|
||||
icon: 'i-lucide-languages',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
|
||||
value: 'delete',
|
||||
@@ -107,9 +100,9 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
};
|
||||
|
||||
export const ARTICLE_MENU_OPTIONS = {
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
|
||||
};
|
||||
|
||||
export const ARTICLE_TABS = {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
calculateMenuPosition,
|
||||
stripUnsupportedFormatting,
|
||||
stripInlineBase64Images,
|
||||
collapseSelection,
|
||||
} from '../editorHelper';
|
||||
import { FORMATTING } from 'dashboard/constants/editor';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
@@ -455,37 +454,6 @@ describe('stripInlineBase64Images', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseSelection', () => {
|
||||
it('collapses a text range to a cursor at its head', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hello world'),
|
||||
});
|
||||
|
||||
// Build a TextSelection via the initial selection's constructor (avoids
|
||||
// importing prosemirror-state, which isn't a direct dep).
|
||||
const { doc, selection } = editorView.state;
|
||||
editorView.dispatch(
|
||||
editorView.state.tr.setSelection(selection.constructor.create(doc, 1, 6))
|
||||
);
|
||||
expect(editorView.state.selection.empty).toBe(false);
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
expect(editorView.state.selection.head).toBe(6);
|
||||
});
|
||||
|
||||
it('leaves an already-collapsed selection as a cursor', () => {
|
||||
const editorView = new EditorView(document.body, {
|
||||
state: createEditorState('Hi'),
|
||||
});
|
||||
|
||||
collapseSelection(editorView);
|
||||
|
||||
expect(editorView.state.selection.empty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertAtCursor', () => {
|
||||
it('should return undefined if editorView is not provided', () => {
|
||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||
|
||||
@@ -22,37 +22,13 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
|
||||
return callDirection === 'outbound' && senderId !== currentUserId;
|
||||
};
|
||||
|
||||
const extractAssigneeId = conversation => {
|
||||
return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
|
||||
};
|
||||
|
||||
const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
|
||||
if (currentUserId == null) return false;
|
||||
return !!assigneeId && assigneeId !== currentUserId;
|
||||
};
|
||||
|
||||
const shouldShowCall = ({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
}) => {
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
|
||||
// Outbound calls are scoped to the initiator via shouldSkipCall; the
|
||||
// conversation may be auto-assigned to a different agent on creation, so
|
||||
// skip the assignee filter for outbound to avoid hiding the caller's own widget.
|
||||
if (callDirection === 'outbound') return true;
|
||||
return !isAssignedToAnotherAgent(assigneeId, currentUserId);
|
||||
};
|
||||
|
||||
function extractCallData(message) {
|
||||
const call = message?.call || {};
|
||||
const contentData = message?.content_attributes?.data || {};
|
||||
return {
|
||||
callSid: call.provider_call_id,
|
||||
status: call.status,
|
||||
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
callSid: contentData.call_sid,
|
||||
status: contentData.status,
|
||||
callDirection: contentData.call_direction,
|
||||
conversationId: message?.conversation_id,
|
||||
assigneeId: extractAssigneeId(message?.conversation),
|
||||
senderId: message?.sender?.id,
|
||||
};
|
||||
}
|
||||
@@ -60,19 +36,10 @@ function extractCallData(message) {
|
||||
export function handleVoiceCallCreated(message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const { callSid, callDirection, conversationId, assigneeId, senderId } =
|
||||
const { callSid, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
@@ -86,38 +53,22 @@ export function handleVoiceCallCreated(message, currentUserId) {
|
||||
export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
if (!isVoiceCallMessage(message)) return;
|
||||
|
||||
const {
|
||||
callSid,
|
||||
status,
|
||||
callDirection,
|
||||
conversationId,
|
||||
assigneeId,
|
||||
senderId,
|
||||
} = extractCallData(message);
|
||||
const { callSid, status, callDirection, conversationId, senderId } =
|
||||
extractCallData(message);
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
|
||||
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
|
||||
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, {
|
||||
conversationId,
|
||||
callStatus: status,
|
||||
callSid,
|
||||
});
|
||||
const callInfo = { conversationId, callStatus: status };
|
||||
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
|
||||
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
|
||||
|
||||
if (
|
||||
!shouldShowCall({
|
||||
callDirection,
|
||||
senderId,
|
||||
assigneeId,
|
||||
currentUserId,
|
||||
})
|
||||
) {
|
||||
callsStore.removeCall(callSid);
|
||||
return;
|
||||
}
|
||||
const isNewCall =
|
||||
status === 'ringing' &&
|
||||
!shouldSkipCall(callDirection, senderId, currentUserId);
|
||||
|
||||
if (status === 'ringing') {
|
||||
if (isNewCall) {
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
@@ -126,11 +77,3 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function syncConversationCallVisibility(conversation, currentUserId) {
|
||||
const assigneeId = extractAssigneeId(conversation);
|
||||
if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
|
||||
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.removeCallsForConversation(conversation.id);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user