Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a1dd481e9 | ||
|
|
a98666030b | ||
|
|
bae20ca83e | ||
|
|
954e5844a8 | ||
|
|
0efab5fb43 | ||
|
|
34ad78b122 | ||
|
|
ddb0535a93 | ||
|
|
42cbf7d3b9 | ||
|
|
887897ea98 | ||
|
|
8aee518149 | ||
|
|
5733b822e3 | ||
|
|
1e52d23d7a | ||
|
|
fbb3479263 | ||
|
|
166a41c31c | ||
|
|
e65e18e9c5 | ||
|
|
89b83c65c8 | ||
|
|
ed30ff9c22 | ||
|
|
7d2f01e402 | ||
|
|
8c013415b8 | ||
|
|
2144de92f2 | ||
|
|
0e376f4fe2 | ||
|
|
67cab7171d | ||
|
|
7a5385cc32 | ||
|
|
920a98ccf4 | ||
|
|
ae49af354d | ||
|
|
d1fa8d8c2f | ||
|
|
71fffdd2b9 | ||
|
|
eae9841eb4 | ||
|
|
160732c07d | ||
|
|
7a299307b8 | ||
|
|
08f49f5896 | ||
|
|
bf0a10c780 | ||
|
|
a752e56765 | ||
|
|
ff6c068743 | ||
|
|
465763f256 | ||
|
|
e70bbfaaa5 | ||
|
|
a61a8bfbc6 | ||
|
|
4cb89d0de1 | ||
|
|
259187e1fd | ||
|
|
90861f8809 | ||
|
|
c420edce58 | ||
|
|
331875cdaa | ||
|
|
5af26e45a9 | ||
|
|
f948b0b8d9 | ||
|
|
081e08c7c6 | ||
|
|
dc3a4814aa | ||
|
|
5e811eab99 | ||
|
|
9749a3dc96 | ||
|
|
8dd0d08322 | ||
|
|
7e88d44fd9 | ||
|
|
2891a72cb9 | ||
|
|
522e3c4d3f | ||
|
|
0a25a0ef66 | ||
|
|
0fccb7dacd | ||
|
|
f1a07657e9 | ||
|
|
9cb6c501b5 | ||
|
|
ab01ab7853 | ||
|
|
6d74ff9477 | ||
|
|
354c2cab6b | ||
|
|
9c68eed676 | ||
|
|
6d38b4d39c | ||
|
|
fd625981e9 | ||
|
|
49c442751d | ||
|
|
13db36609d | ||
|
|
8b4f3e226e | ||
|
|
2ac55c8728 | ||
|
|
9328f8739c | ||
|
|
3e03f8da1e | ||
|
|
280756b483 | ||
|
|
102f19fe41 | ||
|
|
1b6a80d84d | ||
|
|
9c444315a6 | ||
|
|
df7f137657 | ||
|
|
056b5eb89d | ||
|
|
08260f3be7 | ||
|
|
11c65f3b9a | ||
|
|
8fc5c7a5c8 | ||
|
|
7caa4e0bbc | ||
|
|
9c10fe4eb1 | ||
|
|
6b3800e25b | ||
|
|
b560720e08 | ||
|
|
cc87429903 | ||
|
|
98154bbeab | ||
|
|
03a1b1dbc1 | ||
|
|
848e94bcf2 | ||
|
|
56dc580f50 | ||
|
|
35fcd56ba9 | ||
|
|
d57354c8b5 | ||
|
|
8d2ef4ec5e | ||
|
|
bddb561546 | ||
|
|
20227403ce | ||
|
|
0a13dbedfb | ||
|
|
8d554f2b74 | ||
|
|
4854fb7b4b | ||
|
|
66cfb26c77 | ||
|
|
b9536fb8ed | ||
|
|
873d16f54c | ||
|
|
d3c588ff10 | ||
|
|
f6c18f5225 | ||
|
|
0e07a27c74 | ||
|
|
ce8c8e9a11 | ||
|
|
29d0b92f1c | ||
|
|
a5fcecb3f6 | ||
|
|
1e7218d439 | ||
|
|
c8bfbadae2 | ||
|
|
83dda621c5 | ||
|
|
eab859cd04 | ||
|
|
b823764199 | ||
|
|
397ac2e18a | ||
|
|
11deffdd5d |
@@ -77,7 +77,8 @@ jobs:
|
|||||||
|
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '24.13'
|
node-version: '24.13'
|
||||||
- node/install-pnpm
|
- node/install-pnpm:
|
||||||
|
version: '10.2.0'
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
@@ -118,7 +119,8 @@ jobs:
|
|||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '24.13'
|
node-version: '24.13'
|
||||||
- node/install-pnpm
|
- node/install-pnpm:
|
||||||
|
version: '10.2.0'
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
@@ -149,7 +151,8 @@ jobs:
|
|||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
node-version: '24.13'
|
node-version: '24.13'
|
||||||
- node/install-pnpm
|
- node/install-pnpm:
|
||||||
|
version: '10.2.0'
|
||||||
- node/install-packages:
|
- node/install-packages:
|
||||||
pkg-manager: pnpm
|
pkg-manager: pnpm
|
||||||
override-ci-command: pnpm i
|
override-ci-command: pnpm i
|
||||||
|
|||||||
@@ -85,8 +85,8 @@
|
|||||||
## Project-Specific
|
## Project-Specific
|
||||||
|
|
||||||
- **Translations**:
|
- **Translations**:
|
||||||
- Only update `en.yml` and `en.json`
|
- For product and source-string changes, only update `en.yml` and `en.json`; other languages are handled through Crowdin and the community
|
||||||
- Other languages are handled by the community
|
- Crowdin-generated translation sync PRs may update non-English locale files; do not flag those changes solely for modifying translated locale files
|
||||||
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
|
- Backend i18n → `en.yml`, Frontend i18n → `en.json`
|
||||||
- **Frontend**:
|
- **Frontend**:
|
||||||
- Use `components-next/` for message bubbles (the rest is being deprecated)
|
- Use `components-next/` for message bubbles (the rest is being deprecated)
|
||||||
|
|||||||
+17
-15
@@ -211,13 +211,14 @@ GEM
|
|||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
datadog (2.19.0)
|
datadog (2.38.0)
|
||||||
datadog-ruby_core_source (~> 3.4, >= 3.4.1)
|
cgi
|
||||||
libdatadog (~> 18.1.0.1.0)
|
datadog-ruby_core_source (~> 3.5, >= 3.5.3)
|
||||||
libddwaf (~> 1.24.1.0.3)
|
libdatadog (~> 36.0.0.1.0)
|
||||||
|
libddwaf (~> 1.30.0.0.0)
|
||||||
logger
|
logger
|
||||||
msgpack
|
msgpack
|
||||||
datadog-ruby_core_source (3.4.1)
|
datadog-ruby_core_source (3.5.3)
|
||||||
date (3.5.1)
|
date (3.5.1)
|
||||||
debug (1.8.0)
|
debug (1.8.0)
|
||||||
irb (>= 1.5.0)
|
irb (>= 1.5.0)
|
||||||
@@ -522,15 +523,16 @@ GEM
|
|||||||
logger (~> 1.6)
|
logger (~> 1.6)
|
||||||
letter_opener (1.10.0)
|
letter_opener (1.10.0)
|
||||||
launchy (>= 2.2, < 4)
|
launchy (>= 2.2, < 4)
|
||||||
libdatadog (18.1.0.1.0)
|
libdatadog (36.0.0.1.0)
|
||||||
libdatadog (18.1.0.1.0-x86_64-linux)
|
libdatadog (36.0.0.1.0-arm64-darwin)
|
||||||
libddwaf (1.24.1.0.3)
|
libdatadog (36.0.0.1.0-x86_64-linux)
|
||||||
|
libddwaf (1.30.0.0.2)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
libddwaf (1.24.1.0.3-arm64-darwin)
|
libddwaf (1.30.0.0.2-arm64-darwin)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
libddwaf (1.24.1.0.3-x86_64-darwin)
|
libddwaf (1.30.0.0.2-x86_64-darwin)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
libddwaf (1.24.1.0.3-x86_64-linux)
|
libddwaf (1.30.0.0.2-x86_64-linux)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
line-bot-api (1.28.0)
|
line-bot-api (1.28.0)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
@@ -547,7 +549,7 @@ GEM
|
|||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
railties (>= 4)
|
railties (>= 4)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
loofah (2.23.1)
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.8.1)
|
mail (2.8.1)
|
||||||
@@ -740,8 +742,8 @@ GEM
|
|||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.6.1)
|
rails-html-sanitizer (1.7.1)
|
||||||
loofah (~> 2.21)
|
loofah (~> 2.25, >= 2.25.2)
|
||||||
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)
|
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)
|
rails-i18n (7.0.10)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
@@ -1030,7 +1032,7 @@ GEM
|
|||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
hashdiff (>= 0.4.0, < 2.0.0)
|
hashdiff (>= 0.4.0, < 2.0.0)
|
||||||
websocket-driver (0.7.7)
|
websocket-driver (0.8.2)
|
||||||
base64
|
base64
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2017-2024 Chatwoot Inc.
|
Copyright (c) 2017-2026 Chatwoot Inc.
|
||||||
|
|
||||||
Portions of this software are licensed as follows:
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
4.15.1
|
4.16.1
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
# It initializes with necessary attributes and provides a perform method
|
# It initializes with necessary attributes and provides a perform method
|
||||||
# to create a user and account user in a transaction.
|
# to create a user and account user in a transaction.
|
||||||
class AgentBuilder
|
class AgentBuilder
|
||||||
|
LIMIT_EXCEEDED_MESSAGE = 'Account limit exceeded. Please purchase more licenses'.freeze
|
||||||
|
|
||||||
|
class LimitExceededError < StandardError
|
||||||
|
def initialize
|
||||||
|
super(AgentBuilder::LIMIT_EXCEEDED_MESSAGE)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Initializes an AgentBuilder with necessary attributes.
|
# Initializes an AgentBuilder with necessary attributes.
|
||||||
# @param email [String] the email of the user.
|
# @param email [String] the email of the user.
|
||||||
# @param name [String] the name of the user.
|
# @param name [String] the name of the user.
|
||||||
@@ -14,15 +22,23 @@ class AgentBuilder
|
|||||||
# Creates a user and account user in a transaction.
|
# Creates a user and account user in a transaction.
|
||||||
# @return [User] the created user.
|
# @return [User] the created user.
|
||||||
def perform
|
def perform
|
||||||
ActiveRecord::Base.transaction do
|
account.with_lock do
|
||||||
@user = find_or_create_user
|
raise LimitExceededError unless can_add_agent?
|
||||||
create_account_user
|
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
@user = find_or_create_user
|
||||||
|
create_account_user
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@user
|
@user
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def can_add_agent?
|
||||||
|
account.usage_limits[:agents] > account.account_users.count
|
||||||
|
end
|
||||||
|
|
||||||
# Finds a user by email or creates a new one with a temporary password.
|
# Finds a user by email or creates a new one with a temporary password.
|
||||||
# @return [User] the found or created user.
|
# @return [User] the found or created user.
|
||||||
def find_or_create_user
|
def find_or_create_user
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||||
before_action :current_account
|
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :agent_bot, except: [:index, :create]
|
before_action :agent_bot, except: [:index, :create]
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
before_action :validate_limit, only: [:create]
|
|
||||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@agents = agents
|
@agents = agents
|
||||||
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
)
|
)
|
||||||
|
|
||||||
@agent = builder.perform
|
@agent = builder.perform
|
||||||
|
rescue AgentBuilder::LimitExceededError => e
|
||||||
|
render_payment_required(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
def bulk_create
|
def bulk_create
|
||||||
emails = params[:emails]
|
emails = params[:emails]
|
||||||
|
|
||||||
emails.each do |email|
|
bulk_create_agents(emails)
|
||||||
builder = AgentBuilder.new(
|
|
||||||
email: email,
|
|
||||||
name: email.split('@').first,
|
|
||||||
inviter: current_user,
|
|
||||||
account: Current.account
|
|
||||||
)
|
|
||||||
begin
|
|
||||||
builder.perform
|
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
|
||||||
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# This endpoint is used to bulk create agents during onboarding
|
# This endpoint is used to bulk create agents during onboarding
|
||||||
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
# onboarding_step key in present in Current account custom attributes, since this is a one time operation
|
||||||
Current.account.custom_attributes.delete('onboarding_step')
|
clear_onboarding_step
|
||||||
Current.account.save!
|
|
||||||
head :ok
|
head :ok
|
||||||
|
rescue AgentBuilder::LimitExceededError => e
|
||||||
|
render_payment_required(e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -87,22 +75,33 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_limit_for_bulk_create
|
def bulk_create_agents(emails)
|
||||||
limit_available = params[:emails].count <= available_agent_count
|
Current.account.with_lock do
|
||||||
|
raise AgentBuilder::LimitExceededError if emails.count > available_agent_count
|
||||||
|
|
||||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless limit_available
|
emails.each { |email| create_agent_from_email(email) }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_limit
|
def create_agent_from_email(email)
|
||||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
builder = AgentBuilder.new(
|
||||||
|
email: email,
|
||||||
|
name: email.split('@').first,
|
||||||
|
inviter: current_user,
|
||||||
|
account: Current.account
|
||||||
|
)
|
||||||
|
builder.perform
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
Rails.logger.info "[Agent#bulk_create] ignoring email #{email}, errors: #{e.record.errors}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_onboarding_step
|
||||||
|
Current.account.custom_attributes.delete('onboarding_step')
|
||||||
|
Current.account.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def available_agent_count
|
def available_agent_count
|
||||||
Current.account.usage_limits[:agents] - agents.count
|
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||||
end
|
|
||||||
|
|
||||||
def can_add_agent?
|
|
||||||
available_agent_count.positive?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_user_record(agent)
|
def delete_user_record(agent)
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@article.update!(article_params) if params[:article].present?
|
persist_article_changes if params[:article].present?
|
||||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
render json: { message: @article.errors.full_messages.to_sentence }, status: :unprocessable_entity and return unless @article.valid?
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -67,12 +67,26 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Draft-only autosaves must not bump the public-facing updated_at, so write
|
||||||
|
# them with update_columns (which skips the timestamp). update_columns also
|
||||||
|
# skips validations, so assign and validate first to avoid persisting content
|
||||||
|
# that exceeds the column length limit.
|
||||||
|
def persist_article_changes
|
||||||
|
keys = article_params.to_h.keys
|
||||||
|
if keys.any? && (keys - %w[draft_title draft_content]).empty?
|
||||||
|
@article.assign_attributes(article_params)
|
||||||
|
@article.update_columns(article_params.to_h) if @article.valid? # rubocop:disable Rails/SkipsModelValidations
|
||||||
|
else
|
||||||
|
@article.update!(article_params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def article_params
|
def article_params
|
||||||
params.require(:article).permit(
|
params.require(:article).permit(
|
||||||
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
|
||||||
:locale, meta: [:title,
|
:locale, :draft_title, :draft_content, meta: [:title,
|
||||||
:description,
|
:description,
|
||||||
{ tags: [] }]
|
{ tags: [] }]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
|
|||||||
before_action :fetch_inboxes
|
before_action :fetch_inboxes
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
|
||||||
|
@include_agent_bots = params[:include_agent_bots].present?
|
||||||
agent_ids = @inboxes.map do |inbox|
|
agent_ids = @inboxes.map do |inbox|
|
||||||
authorize inbox, :show?
|
authorize inbox, :show?
|
||||||
member_ids = inbox.members.pluck(:user_id)
|
member_ids = inbox.members.pluck(:user_id)
|
||||||
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
|
|||||||
agent_ids = agent_ids.inject(:&)
|
agent_ids = agent_ids.inject(:&)
|
||||||
agents = Current.account.users.where(id: agent_ids)
|
agents = Current.account.users.where(id: agent_ids)
|
||||||
@assignable_agents = (agents + Current.account.administrators).uniq
|
@assignable_agents = (agents + Current.account.administrators).uniq
|
||||||
|
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -2,5 +2,14 @@ class Api::V1::Accounts::BaseController < Api::BaseController
|
|||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
include EnsureCurrentAccountHelper
|
include EnsureCurrentAccountHelper
|
||||||
before_action :current_account
|
before_action :current_account
|
||||||
|
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||||
around_action :switch_locale_using_account_locale
|
around_action :switch_locale_using_account_locale
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_token_api_access
|
||||||
|
return if Current.account.api_and_webhooks_enabled?
|
||||||
|
|
||||||
|
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
class Api::V1::Accounts::BrandedEmailLayoutsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :check_admin_authorization?
|
||||||
|
|
||||||
|
def show
|
||||||
|
set_branded_email_layout
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||||
|
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||||
|
EmailTemplate.update_account_branded_layout!(account: Current.account, body: branded_email_layout) if params.key?(:branded_email_layout)
|
||||||
|
set_branded_email_layout
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_branded_email_layout
|
||||||
|
@branded_email_layout = EmailTemplate.account_branded_layout_template_for(Current.account)&.body
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Api::V1::Accounts::BrandedEmailLayoutsController.prepend_mod_with('Api::V1::Accounts::BrandedEmailLayoutsController')
|
||||||
@@ -6,6 +6,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||||||
page_access_token = params[:page_access_token]
|
page_access_token = params[:page_access_token]
|
||||||
page_id = params[:page_id]
|
page_id = params[:page_id]
|
||||||
inbox_name = params[:inbox_name]
|
inbox_name = params[:inbox_name]
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
facebook_channel = Current.account.facebook_pages.create!(
|
facebook_channel = Current.account.facebook_pages.create!(
|
||||||
page_id: page_id, user_access_token: user_access_token,
|
page_id: page_id, user_access_token: user_access_token,
|
||||||
@@ -15,6 +16,8 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
|||||||
set_instagram_id(page_access_token, facebook_channel)
|
set_instagram_id(page_access_token, facebook_channel)
|
||||||
set_avatar(@facebook_inbox, page_id)
|
set_avatar(@facebook_inbox, page_id)
|
||||||
end
|
end
|
||||||
|
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||||
|
render_error_response(e)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
ChatwootExceptionTracker.new(e).capture_exception
|
ChatwootExceptionTracker.new(e).capture_exception
|
||||||
Rails.logger.error "Error in register_facebook_page: #{e.message}"
|
Rails.logger.error "Error in register_facebook_page: #{e.message}"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||||
before_action :current_account
|
|
||||||
before_action :authorize_account_update, only: [:update]
|
before_action :authorize_account_update, only: [:update]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@@ -66,6 +65,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
|||||||
config = Llm::Models.feature_config(feature_key)
|
config = Llm::Models.feature_config(feature_key)
|
||||||
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
|
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
|
||||||
config.merge(
|
config.merge(
|
||||||
|
default: default_model_for(feature_key),
|
||||||
enabled: account_features[feature_key] == true,
|
enabled: account_features[feature_key] == true,
|
||||||
model: route[:model],
|
model: route[:model],
|
||||||
selected: route[:model],
|
selected: route[:model],
|
||||||
@@ -74,4 +74,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def default_model_for(feature_key)
|
||||||
|
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
|
||||||
|
|
||||||
|
Llm::Models.default_model_for(feature_key)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
process_create
|
process_create
|
||||||
|
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||||
|
render_error_response(e)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||||
|
include DeviseTokenAuth::Concerns::SetUserByToken
|
||||||
|
include RequestExceptionHandler
|
||||||
|
include AccessTokenAuthHelper
|
||||||
include EnsureCurrentAccountHelper
|
include EnsureCurrentAccountHelper
|
||||||
|
|
||||||
|
skip_before_action :verify_authenticity_token, if: :authenticate_by_access_token?
|
||||||
|
|
||||||
|
around_action :handle_with_exception
|
||||||
|
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
|
||||||
|
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
|
||||||
|
before_action :authenticate_user!, unless: :authenticate_by_access_token?
|
||||||
before_action :current_account
|
before_action :current_account
|
||||||
|
before_action :validate_token_api_access, if: :authenticate_by_access_token?
|
||||||
before_action :conversation
|
before_action :conversation
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@@ -11,6 +22,16 @@ class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage:
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def authenticate_by_access_token?
|
||||||
|
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_token_api_access
|
||||||
|
return if Current.account.api_and_webhooks_enabled?
|
||||||
|
|
||||||
|
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
def conversation
|
def conversation
|
||||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,27 +1,40 @@
|
|||||||
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
|
class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accounts::Conversations::BaseController
|
||||||
|
include Events::Types
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@participants = @conversation.conversation_participants
|
@participants = @conversation.conversation_participants
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
participant_ids_to_add = participants_to_be_added_ids
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
@participants = participants_to_be_added_ids.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
@participants = participant_ids_to_add.map { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
||||||
end
|
end
|
||||||
|
notify_unread_count_change if participant_ids_to_add.any?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
participant_ids_to_add = participants_to_be_added_ids
|
||||||
|
participant_ids_to_remove = participants_to_be_removed_ids
|
||||||
|
changed_participant_ids = participant_ids_to_add + participant_ids_to_remove
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
participants_to_be_added_ids.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
participant_ids_to_add.each { |user_id| @conversation.conversation_participants.find_or_create_by(user_id: user_id) }
|
||||||
participants_to_be_removed_ids.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
participant_ids_to_remove.each { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
||||||
end
|
end
|
||||||
|
notify_unread_count_change if changed_participant_ids.any?
|
||||||
@participants = @conversation.conversation_participants
|
@participants = @conversation.conversation_participants
|
||||||
render action: 'show'
|
render action: 'show'
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
participant_ids_to_remove = current_participant_ids & params[:user_ids]
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
params[:user_ids].map { |user_id| @conversation.conversation_participants.find_by(user_id: user_id)&.destroy }
|
||||||
end
|
end
|
||||||
|
notify_unread_count_change if participant_ids_to_remove.any?
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -38,4 +51,11 @@ class Api::V1::Accounts::Conversations::ParticipantsController < Api::V1::Accoun
|
|||||||
def current_participant_ids
|
def current_participant_ids
|
||||||
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
|
@current_participant_ids ||= @conversation.conversation_participants.pluck(:user_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def notify_unread_count_change
|
||||||
|
return unless Current.account.feature_enabled?('conversation_unread_counts')
|
||||||
|
return unless Current.account.feature_enabled?('unread_count_for_filters')
|
||||||
|
|
||||||
|
Rails.configuration.dispatcher.dispatch(CONVERSATION_UNREAD_COUNT_CHANGED, Time.zone.now, conversation: @conversation)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,12 +2,28 @@ class Api::V1::Accounts::Conversations::UnreadCountsController < Api::V1::Accoun
|
|||||||
before_action :ensure_unread_counts_enabled
|
before_action :ensure_unread_counts_enabled
|
||||||
|
|
||||||
def index
|
def index
|
||||||
counts = ::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
counts = if filtered_unread_counts_enabled?
|
||||||
|
instrumentation.summarize_request(account_id: Current.account.id) { unread_counts }
|
||||||
|
else
|
||||||
|
unread_counts
|
||||||
|
end
|
||||||
render json: { payload: counts }
|
render json: { payload: counts }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def unread_counts
|
||||||
|
::Conversations::UnreadCounts::Counter.new(account: Current.account, user: Current.user).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_unread_counts_enabled?
|
||||||
|
Current.account.feature_enabled?(::Conversations::UnreadCounts::FilteredCounter::FEATURE_FLAG)
|
||||||
|
end
|
||||||
|
|
||||||
|
def instrumentation
|
||||||
|
::Conversations::UnreadCounts::FilteredCountInstrumentation
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_unread_counts_enabled
|
def ensure_unread_counts_enabled
|
||||||
return if Current.account.feature_enabled?('conversation_unread_counts')
|
return if Current.account.feature_enabled?('conversation_unread_counts')
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
::Conversations::UnreadCounts::Notifier.new(@conversation).perform
|
||||||
|
::Conversations::UnreadCounts::FilteredCountInvalidator.new(Current.account).conversation_changed!
|
||||||
end
|
end
|
||||||
|
|
||||||
def should_update_last_seen?
|
def should_update_last_seen?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :check_authorization
|
||||||
before_action :fetch_dashboard_apps, except: [:create]
|
before_action :fetch_dashboard_apps, except: [:create]
|
||||||
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
|
before_action :fetch_dashboard_app, only: [:show, :update, :destroy]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
require 'csv'
|
||||||
|
|
||||||
|
class Api::V1::Accounts::DataImportsController < Api::V1::Accounts::BaseController
|
||||||
|
DATA_IMPORT_FEATURE = 'data_import'.freeze
|
||||||
|
|
||||||
|
before_action :ensure_data_import_feature_enabled
|
||||||
|
before_action :set_data_import, only: [:show, :start, :abandon, :error_logs, :skip_logs]
|
||||||
|
before_action :check_authorization
|
||||||
|
|
||||||
|
def index
|
||||||
|
@data_imports = policy_scope(Current.account.data_imports).includes(:initiated_by).order(created_at: :desc)
|
||||||
|
data_import_ids = @data_imports.map(&:id)
|
||||||
|
@import_errors_counts = DataImportError.non_skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
|
||||||
|
@skip_logs_counts = DataImportError.skip_logs.where(data_import_id: data_import_ids).group(:data_import_id).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render_show
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_source
|
||||||
|
totals = validate_intercom_source
|
||||||
|
render json: { valid: true, totals: totals }
|
||||||
|
rescue DataImports::Intercom::Client::AuthenticationError
|
||||||
|
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
|
||||||
|
rescue DataImports::Intercom::Client::Error
|
||||||
|
render_source_validation_error('Intercom could not be reached. Please try again.')
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_source_validation_error(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@data_import = creation_service.perform
|
||||||
|
unless @data_import
|
||||||
|
render json: { message: 'Another data import is already in progress.' }, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id)
|
||||||
|
render_show
|
||||||
|
rescue DataImports::Intercom::Client::AuthenticationError
|
||||||
|
render_source_validation_error('We could not validate this Intercom access key. Check the key and its permissions.')
|
||||||
|
rescue DataImports::Intercom::Client::Error
|
||||||
|
render_source_validation_error('Intercom could not be reached. Please try again.')
|
||||||
|
rescue ArgumentError => e
|
||||||
|
render_source_validation_error(e.message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def start
|
||||||
|
restart_service = DataImports::Intercom::RestartService.new(account: Current.account, data_import: @data_import)
|
||||||
|
restart_result = restart_service.perform
|
||||||
|
@data_import = restart_service.data_import
|
||||||
|
if restart_result == :access_token_missing
|
||||||
|
render json: { message: 'The Intercom access key for this import is unavailable.' }, status: :unprocessable_entity
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
DataImports::Intercom::ImportJob.perform_later(@data_import, @data_import.active_intercom_import_run_id) if restart_result == :enqueue
|
||||||
|
render_show
|
||||||
|
end
|
||||||
|
|
||||||
|
def abandon
|
||||||
|
@data_import.abandon!
|
||||||
|
render_show
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_logs
|
||||||
|
send_data(
|
||||||
|
skip_logs_csv,
|
||||||
|
filename: "data-import-#{@data_import.id}-skip-logs.csv",
|
||||||
|
type: 'text/csv'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_logs
|
||||||
|
send_data(
|
||||||
|
error_logs_csv,
|
||||||
|
filename: "data-import-#{@data_import.id}-error-logs.csv",
|
||||||
|
type: 'text/csv'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def ensure_data_import_feature_enabled
|
||||||
|
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?(DATA_IMPORT_FEATURE)
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_data_import
|
||||||
|
@data_import = Current.account.data_imports.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_authorization
|
||||||
|
authorize(@data_import || DataImport)
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_params
|
||||||
|
params.permit(:name, :source_provider, :access_token, import_types: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def creation_service
|
||||||
|
DataImports::Intercom::CreationService.new(
|
||||||
|
account: Current.account,
|
||||||
|
initiated_by: Current.user,
|
||||||
|
source_params: permitted_params.to_h
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_types
|
||||||
|
return DataImports::Intercom::Importer::DEFAULT_IMPORT_TYPES unless permitted_params.key?(:import_types)
|
||||||
|
|
||||||
|
Array(permitted_params[:import_types]).compact_blank
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_intercom_source
|
||||||
|
raise ArgumentError, 'Unsupported import source.' unless permitted_params[:source_provider] == 'intercom'
|
||||||
|
|
||||||
|
DataImports::Intercom::CredentialsValidator.new(
|
||||||
|
access_token: permitted_params[:access_token],
|
||||||
|
import_types: import_types
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_source_validation_error(message)
|
||||||
|
render json: { valid: false, message: message }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_show
|
||||||
|
@import_errors_finder = DataImportErrorFinder.new(@data_import)
|
||||||
|
@skip_logs_finder = DataImportSkipLogFinder.new(@data_import, params)
|
||||||
|
render :show
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_logs_csv
|
||||||
|
logs_csv(@data_import.import_errors.skip_logs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def error_logs_csv
|
||||||
|
logs_csv(@data_import.import_errors.non_skip_logs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def logs_csv(logs)
|
||||||
|
CSV.generate(headers: true) do |csv|
|
||||||
|
csv << %w[created_at kind source_object_type source_object_id error_code message details]
|
||||||
|
|
||||||
|
logs.order(:created_at).find_each do |log|
|
||||||
|
csv << [
|
||||||
|
log.created_at.iso8601,
|
||||||
|
log.details['kind'],
|
||||||
|
log.source_object_type,
|
||||||
|
log.source_object_id,
|
||||||
|
log.error_code,
|
||||||
|
log.message,
|
||||||
|
log.details.to_json
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,7 +2,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
include Api::V1::InboxesHelper
|
include Api::V1::InboxesHelper
|
||||||
before_action :fetch_inbox, except: [:index, :create]
|
before_action :fetch_inbox, except: [:index, :create]
|
||||||
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
before_action :fetch_agent_bot, only: [:set_agent_bot]
|
||||||
before_action :validate_limit, only: [:create]
|
|
||||||
# we are already handling the authorization in fetch inbox
|
# we are already handling the authorization in fetch inbox
|
||||||
before_action :check_authorization, except: [:show]
|
before_action :check_authorization, except: [:show]
|
||||||
|
|
||||||
@@ -46,11 +45,20 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
inbox_params = permitted_params.except(:channel, :csat_config)
|
continue_update = false
|
||||||
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
|
||||||
@inbox.update!(inbox_params)
|
ActiveRecord::Base.transaction do
|
||||||
update_inbox_working_hours
|
continue_update = update_branded_email_layout
|
||||||
update_channel if channel_update_required?
|
raise ActiveRecord::Rollback unless continue_update
|
||||||
|
|
||||||
|
inbox_params = permitted_params.except(:channel, :csat_config)
|
||||||
|
inbox_params[:csat_config] = format_csat_config(permitted_params[:csat_config]) if permitted_params[:csat_config].present?
|
||||||
|
@inbox.update!(inbox_params)
|
||||||
|
update_inbox_working_hours
|
||||||
|
update_channel if channel_update_required?
|
||||||
|
end
|
||||||
|
|
||||||
|
return unless continue_update
|
||||||
end
|
end
|
||||||
|
|
||||||
def agent_bot
|
def agent_bot
|
||||||
@@ -125,8 +133,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reauthorize_and_update_channel(channel_attributes)
|
def reauthorize_and_update_channel(channel_attributes)
|
||||||
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
|
||||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||||
|
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_channel_feature_flags
|
def update_channel_feature_flags
|
||||||
@@ -156,6 +164,34 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
formatted['template'] = config['template'] if config['template'].present?
|
formatted['template'] = config['template'] if config['template'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_branded_email_layout
|
||||||
|
return true unless params.key?(:branded_email_layout)
|
||||||
|
|
||||||
|
branded_email_layout = normalized_branded_email_layout
|
||||||
|
|
||||||
|
unless Current.account.feature_enabled?(:branded_email_templates)
|
||||||
|
return true if branded_email_layout.blank?
|
||||||
|
|
||||||
|
render_could_not_create_error('Branded email templates feature is not enabled')
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
unless @inbox.email?
|
||||||
|
return true if branded_email_layout.blank?
|
||||||
|
|
||||||
|
render_could_not_create_error('Branded email layout is only supported for email inboxes')
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
@inbox.update_branded_email_layout!(branded_email_layout)
|
||||||
|
true
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
render_could_not_create_error(e.record.errors.full_messages.join(', '))
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_branded_email_layout = params[:branded_email_layout] == 'null' ? nil : params[:branded_email_layout]
|
||||||
|
|
||||||
def inbox_attributes
|
def inbox_attributes
|
||||||
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
[:name, :avatar, :greeting_enabled, :greeting_message, :enable_email_collect, :csat_survey_enabled,
|
||||||
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
:enable_auto_assignment, :working_hours_enabled, :out_of_office_message, :timezone, :allow_messages_after_resolved,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
class Api::V1::Accounts::Integrations::BaseController < Api::V1::Accounts::BaseController
|
||||||
|
private
|
||||||
|
|
||||||
|
# Managing an integration hook (create/update/destroy) is admin-only, enforced via HookPolicy.
|
||||||
|
# Subclasses opt in per action with `before_action :check_authorization, only: [...]`.
|
||||||
|
def check_authorization
|
||||||
|
authorize(:hook)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Integrations::BaseController
|
||||||
before_action :fetch_hook, except: [:create]
|
before_action :fetch_hook, except: [:create]
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
|
||||||
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||||||
@hook = Current.account.hooks.find(params[:id])
|
@hook = Current.account.hooks.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_authorization
|
|
||||||
authorize(:hook)
|
|
||||||
end
|
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Integrations::BaseController
|
||||||
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
|
||||||
before_action :fetch_hook, only: [:destroy]
|
before_action :fetch_hook, only: [:destroy]
|
||||||
|
before_action :check_authorization, only: [:destroy]
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
revoke_linear_token
|
revoke_linear_token
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Integrations::BaseController
|
||||||
before_action :fetch_hook, only: [:destroy]
|
before_action :fetch_hook, only: [:destroy]
|
||||||
|
before_action :check_authorization, only: [:destroy]
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@hook.destroy!
|
@hook.destroy!
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Integrations::BaseController
|
||||||
include Shopify::IntegrationHelper
|
include Shopify::IntegrationHelper
|
||||||
before_action :setup_shopify_context, only: [:orders]
|
before_action :setup_shopify_context, only: [:orders]
|
||||||
before_action :fetch_hook, except: [:auth]
|
before_action :fetch_hook, except: [:auth]
|
||||||
|
before_action :check_authorization, only: [:destroy]
|
||||||
before_action :validate_contact, only: [:orders]
|
before_action :validate_contact, only: [:orders]
|
||||||
|
|
||||||
def auth
|
def auth
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||||
before_action :current_account
|
|
||||||
before_action :fetch_label, except: [:index, :create]
|
before_action :fetch_label, except: [:index, :create]
|
||||||
before_action :check_authorization
|
before_action :check_authorization
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
|||||||
{
|
{
|
||||||
redirect_uri: "#{base_url}/microsoft/callback",
|
redirect_uri: "#{base_url}/microsoft/callback",
|
||||||
scope: scope,
|
scope: scope,
|
||||||
state: state
|
state: state,
|
||||||
|
# Force the Microsoft account picker so an already-signed-in account does not
|
||||||
|
# silently authorize and re-bind to an existing inbox in the new-inbox flow.
|
||||||
|
prompt: 'select_account'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if redirect_url
|
if redirect_url
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
:name, :page_title, :slug, :archived,
|
:name, :page_title, :slug, :archived,
|
||||||
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
|
||||||
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
|
||||||
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
|
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } },
|
||||||
|
{ popular_content: popular_content_keys.index_with { { category_ids: [], article_ids: [] } } }] }
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -89,6 +90,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
|||||||
params.dig(:portal, :config, :locale_translations)&.keys || []
|
params.dig(:portal, :config, :locale_translations)&.keys || []
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def popular_content_keys
|
||||||
|
params.dig(:portal, :config, :popular_content)&.keys || []
|
||||||
|
end
|
||||||
|
|
||||||
def live_chat_widget_params
|
def live_chat_widget_params
|
||||||
permitted_params = params.permit(:inbox_id)
|
permitted_params = params.permit(:inbox_id)
|
||||||
return {} unless permitted_params.key?(:inbox_id)
|
return {} unless permitted_params.key?(:inbox_id)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||||
|
before_action :ensure_embedded_signup_enabled
|
||||||
|
# Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins.
|
||||||
|
before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? }
|
||||||
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? }
|
||||||
|
|
||||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||||
@@ -8,12 +11,21 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
|||||||
validate_embedded_signup_params!
|
validate_embedded_signup_params!
|
||||||
channel = process_embedded_signup
|
channel = process_embedded_signup
|
||||||
render_success_response(channel.inbox)
|
render_success_response(channel.inbox)
|
||||||
rescue StandardError => e
|
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||||
render_error_response(e)
|
render_error_response(e)
|
||||||
|
rescue StandardError => e
|
||||||
|
render_embedded_signup_error(e)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def ensure_embedded_signup_enabled
|
||||||
|
return unless ChatwootApp.chatwoot_cloud?
|
||||||
|
return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation')
|
||||||
|
|
||||||
|
raise Pundit::NotAuthorizedError
|
||||||
|
end
|
||||||
|
|
||||||
def process_embedded_signup
|
def process_embedded_signup
|
||||||
service = Whatsapp::EmbeddedSignupService.new(
|
service = Whatsapp::EmbeddedSignupService.new(
|
||||||
account: Current.account,
|
account: Current.account,
|
||||||
@@ -29,7 +41,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_reauthorization_required
|
def validate_reauthorization_required
|
||||||
return if @inbox.channel.reauthorization_required? || can_upgrade_to_embedded_signup?
|
return if @inbox.channel.reauthorization_required? || can_reconfigure_channel?
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -37,9 +49,11 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
|||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def can_upgrade_to_embedded_signup?
|
def can_reconfigure_channel?
|
||||||
channel = @inbox.channel
|
channel = @inbox.channel
|
||||||
return false unless channel.provider == 'whatsapp_cloud'
|
return false unless channel.provider == 'whatsapp_cloud'
|
||||||
|
return true if ChatwootApp.chatwoot_cloud?
|
||||||
|
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -55,7 +69,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
|||||||
render json: response
|
render json: response
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_error_response(error)
|
def render_embedded_signup_error(error)
|
||||||
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
||||||
Rails.logger.error error.backtrace.join("\n")
|
Rails.logger.error error.backtrace.join("\n")
|
||||||
render json: {
|
render json: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
before_action :ensure_account_name, only: [:create]
|
before_action :ensure_account_name, only: [:create]
|
||||||
before_action :validate_captcha, only: [:create]
|
before_action :validate_captcha, only: [:create]
|
||||||
before_action :fetch_account, except: [:create]
|
before_action :fetch_account, except: [:create]
|
||||||
|
before_action :validate_token_api_access, if: :authenticate_by_access_token?, except: [:create]
|
||||||
before_action :check_authorization, except: [:create]
|
before_action :check_authorization, except: [:create]
|
||||||
|
|
||||||
rescue_from CustomExceptions::Account::InvalidEmail,
|
rescue_from CustomExceptions::Account::InvalidEmail,
|
||||||
@@ -105,6 +106,12 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_token_api_access
|
||||||
|
return if @account.api_and_webhooks_enabled?
|
||||||
|
|
||||||
|
render json: { error: 'API access is not enabled for this account' }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ class Api::V1::Widget::BaseController < ApplicationController
|
|||||||
permitted_params.dig(:contact, :phone_number)
|
permitted_params.dig(:contact, :phone_number)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def contact_custom_attributes
|
||||||
|
permitted_params.dig(:contact, :custom_attributes)&.to_h
|
||||||
|
end
|
||||||
|
|
||||||
def browser_params
|
def browser_params
|
||||||
{
|
{
|
||||||
browser_name: browser.name,
|
browser_name: browser.name,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||||||
include WidgetHelper
|
include WidgetHelper
|
||||||
|
|
||||||
before_action :validate_hmac, only: [:set_user]
|
before_action :validate_hmac, only: [:set_user]
|
||||||
|
before_action :validate_hmac_for_identified_update, only: [:update]
|
||||||
|
|
||||||
def show; end
|
def show; end
|
||||||
|
|
||||||
@@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||||||
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
|
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The plain update endpoint is also used for anonymous prechat updates
|
||||||
|
# (name/email/phone/custom_attributes with no identifier), which must keep
|
||||||
|
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
|
||||||
|
# identifier is supplied and the contact can be rebound, requires HMAC.
|
||||||
|
def validate_hmac_for_identified_update
|
||||||
|
return if params[:identifier].blank?
|
||||||
|
|
||||||
|
validate_hmac
|
||||||
|
end
|
||||||
|
|
||||||
def validate_hmac
|
def validate_hmac
|
||||||
return unless should_verify_hmac?
|
return unless should_verify_hmac?
|
||||||
|
|
||||||
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def valid_hmac?
|
def valid_hmac?
|
||||||
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
|
expected_hash = OpenSSL::HMAC.hexdigest(
|
||||||
'sha256',
|
'sha256',
|
||||||
@web_widget.hmac_token,
|
@web_widget.hmac_token,
|
||||||
params[:identifier].to_s
|
params[:identifier].to_s
|
||||||
)
|
)
|
||||||
|
identifier_hash = params[:identifier_hash].to_s
|
||||||
|
return false unless identifier_hash.bytesize == expected_hash.bytesize
|
||||||
|
|
||||||
|
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
def process_update_contact
|
def process_update_contact
|
||||||
@contact = ContactIdentifyAction.new(
|
@contact = ContactIdentifyAction.new(
|
||||||
contact: @contact,
|
contact: @contact,
|
||||||
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
|
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name, custom_attributes: contact_custom_attributes },
|
||||||
retain_original_contact_name: true,
|
retain_original_contact_name: true,
|
||||||
discard_invalid_attrs: true
|
discard_invalid_attrs: true
|
||||||
).perform
|
).perform
|
||||||
@@ -95,7 +95,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
|
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number, { custom_attributes: {} }],
|
||||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||||
custom_attributes: {})
|
custom_attributes: {})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ module EnsureCurrentAccountHelper
|
|||||||
account_accessible_for_user?(account)
|
account_accessible_for_user?(account)
|
||||||
elsif @resource.is_a?(AgentBot)
|
elsif @resource.is_a?(AgentBot)
|
||||||
account_accessible_for_bot?(account)
|
account_accessible_for_bot?(account)
|
||||||
|
else
|
||||||
|
render_unauthorized(I18n.t('errors.account.not_authorized'))
|
||||||
end
|
end
|
||||||
account
|
account
|
||||||
end
|
end
|
||||||
@@ -21,7 +23,7 @@ module EnsureCurrentAccountHelper
|
|||||||
def account_accessible_for_user?(account)
|
def account_accessible_for_user?(account)
|
||||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||||
Current.account_user = @current_account_user
|
Current.account_user = @current_account_user
|
||||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
render_unauthorized(I18n.t('errors.account.not_authorized')) unless @current_account_user
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_accessible_for_bot?(account)
|
def account_accessible_for_bot?(account)
|
||||||
|
|||||||
@@ -4,17 +4,53 @@ module PortalHomeData
|
|||||||
private
|
private
|
||||||
|
|
||||||
def load_home_data
|
def load_home_data
|
||||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
load_recommended_content
|
||||||
|
# The classic hero only needs the recommendations above; the rest is
|
||||||
|
# documentation-layout home data (also used on custom-domain home pages).
|
||||||
|
return unless @portal.layout == 'documentation'
|
||||||
|
|
||||||
@visible_categories = @portal.categories
|
@visible_categories = @portal.categories
|
||||||
.where(locale: @locale)
|
.where(locale: @locale)
|
||||||
.joins(:articles).where(articles: { status: :published })
|
.joins(:articles).where(articles: { status: :published })
|
||||||
.order(position: :asc)
|
.order(position: :asc)
|
||||||
.group('categories.id')
|
.group('categories.id')
|
||||||
@popular_topics = @visible_categories.first(3)
|
@popular_topics = @recommended_categories.presence || @visible_categories.first(3)
|
||||||
@featured = base_articles.order_by_views.limit(6)
|
@featured = base_articles.order_by_views.limit(6)
|
||||||
@category_contributors = build_category_contributors(@visible_categories)
|
@category_contributors = build_category_contributors(@visible_categories)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def load_recommended_content
|
||||||
|
@recommended_categories = recommended_categories
|
||||||
|
@recommended_articles = recommended_articles
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_articles
|
||||||
|
@base_articles ||= @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Admin-recommended categories for the locale, in the chosen order. Unlike the
|
||||||
|
# position-based fallback, published articles aren't required: the admin's pick wins.
|
||||||
|
def recommended_categories
|
||||||
|
ids = @portal.popular_category_ids(@locale)
|
||||||
|
ordered_by_ids(@portal.categories.where(locale: @locale, id: ids), ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Admin-recommended articles for the locale, in the chosen order, limited to
|
||||||
|
# published articles that still exist.
|
||||||
|
def recommended_articles
|
||||||
|
ids = @portal.popular_article_ids(@locale)
|
||||||
|
ordered_by_ids(base_articles.where(id: ids), ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Loads the scope and returns its records ordered to match `ids`, dropping any
|
||||||
|
# that no longer exist. Skips the query entirely when `ids` is blank.
|
||||||
|
def ordered_by_ids(scope, ids)
|
||||||
|
return [] if ids.blank?
|
||||||
|
|
||||||
|
by_id = scope.index_by(&:id)
|
||||||
|
ids.filter_map { |id| by_id[id] }
|
||||||
|
end
|
||||||
|
|
||||||
def build_category_contributors(categories)
|
def build_category_contributors(categories)
|
||||||
category_ids = categories.map(&:id)
|
category_ids = categories.map(&:id)
|
||||||
return {} if category_ids.empty?
|
return {} if category_ids.empty?
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
module RequestExceptionHandler
|
module RequestExceptionHandler
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
|
||||||
|
'ActiveRecord::QueryCanceled',
|
||||||
|
'PG::QueryCanceled',
|
||||||
|
'canceling statement due to statement timeout'
|
||||||
|
].freeze
|
||||||
|
|
||||||
included do
|
included do
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||||
|
rescue_from CustomExceptions::Inbox::LimitExceeded, with: :render_error_response
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -18,6 +25,9 @@ module RequestExceptionHandler
|
|||||||
rescue ActionController::ParameterMissing => e
|
rescue ActionController::ParameterMissing => e
|
||||||
log_handled_error(e)
|
log_handled_error(e)
|
||||||
render_could_not_create_error(e.message)
|
render_could_not_create_error(e.message)
|
||||||
|
rescue ActiveRecord::QueryCanceled => e
|
||||||
|
log_handled_error(e)
|
||||||
|
render_could_not_create_error(database_query_canceled_message)
|
||||||
ensure
|
ensure
|
||||||
# to address the thread variable leak issues in Puma/Thin webserver
|
# to address the thread variable leak issues in Puma/Thin webserver
|
||||||
Current.reset
|
Current.reset
|
||||||
@@ -31,8 +41,8 @@ module RequestExceptionHandler
|
|||||||
render json: { error: message }, status: :not_found
|
render json: { error: message }, status: :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_could_not_create_error(message)
|
def render_could_not_create_error(error)
|
||||||
render json: { error: message }, status: :unprocessable_entity
|
render json: { error: sanitized_error_message(error) }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def render_payment_required(message)
|
def render_payment_required(message)
|
||||||
@@ -59,4 +69,19 @@ module RequestExceptionHandler
|
|||||||
def log_handled_error(exception)
|
def log_handled_error(exception)
|
||||||
logger.info("Handled error: #{exception.inspect}")
|
logger.info("Handled error: #{exception.inspect}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sanitized_error_message(message)
|
||||||
|
return database_query_canceled_message if database_query_canceled_message?(message)
|
||||||
|
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
|
def database_query_canceled_message?(message)
|
||||||
|
error_message = message.to_s
|
||||||
|
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def database_query_canceled_message
|
||||||
|
I18n.t('errors.database.query_canceled')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -64,10 +64,8 @@ class DashboardController < ActionController::Base
|
|||||||
return unless @portal
|
return unless @portal
|
||||||
|
|
||||||
@locale = @portal.default_locale
|
@locale = @portal.default_locale
|
||||||
if @portal.layout == 'documentation'
|
request.variant = :documentation if @portal.layout == 'documentation'
|
||||||
request.variant = :documentation
|
load_home_data
|
||||||
load_home_data
|
|
||||||
end
|
|
||||||
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class Instagram::CallbacksController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
process_successful_authorization
|
process_successful_authorization
|
||||||
|
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||||
|
handle_limit_error(e)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
handle_error(e)
|
handle_error(e)
|
||||||
end
|
end
|
||||||
@@ -47,6 +49,14 @@ class Instagram::CallbacksController < ApplicationController
|
|||||||
redirect_to_error_page(error_info)
|
redirect_to_error_page(error_info)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_limit_error(error)
|
||||||
|
redirect_to_error_page(
|
||||||
|
'error_type' => error.class.name,
|
||||||
|
'code' => Rack::Utils.status_code(error.http_status),
|
||||||
|
'error_message' => error.message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# Extract error details from the exception
|
# Extract error details from the exception
|
||||||
def extract_error_info(error)
|
def extract_error_info(error)
|
||||||
if error.is_a?(OAuth2::Error)
|
if error.is_a?(OAuth2::Error)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
|||||||
contact: @contact_inbox.contact,
|
contact: @contact_inbox.contact,
|
||||||
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
|
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
|
||||||
)
|
)
|
||||||
render json: contact_identify_action.perform
|
contact_identify_action.perform
|
||||||
|
@contact_inbox.reload
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -35,11 +36,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
|||||||
end
|
end
|
||||||
|
|
||||||
def valid_hmac?
|
def valid_hmac?
|
||||||
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
|
expected_hash = OpenSSL::HMAC.hexdigest(
|
||||||
'sha256',
|
'sha256',
|
||||||
@inbox_channel.hmac_token,
|
@inbox_channel.hmac_token,
|
||||||
params[:identifier].to_s
|
params[:identifier].to_s
|
||||||
)
|
)
|
||||||
|
identifier_hash = params[:identifier_hash].to_s
|
||||||
|
return false unless identifier_hash.bytesize == expected_hash.bytesize
|
||||||
|
|
||||||
|
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_params
|
def permitted_params
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def set_view_variant
|
def set_view_variant
|
||||||
request.variant = :documentation if @portal_layout == 'documentation' && !@is_plain_layout_enabled
|
request.variant = if @is_plain_layout_enabled
|
||||||
|
:plain
|
||||||
|
elsif @portal_layout == 'documentation'
|
||||||
|
:documentation
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def portal
|
def portal
|
||||||
@@ -65,6 +69,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
|
|
||||||
def render_404
|
def render_404
|
||||||
portal
|
portal
|
||||||
|
# set_locale can render_404 before the child's set_view_variant runs; set it here so plain 404s stay chrome-less
|
||||||
|
set_view_variant
|
||||||
render 'public/api/v1/portals/error/404', status: :not_found
|
render 'public/api/v1/portals/error/404', status: :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
before_action :set_portal_layout
|
before_action :set_portal_layout
|
||||||
before_action :set_view_variant
|
before_action :set_view_variant
|
||||||
before_action :ensure_portal_feature_enabled
|
before_action :ensure_portal_feature_enabled
|
||||||
before_action :load_home_data, only: [:show], if: -> { @portal_layout == 'documentation' }
|
before_action :load_home_data, only: [:show], unless: -> { @is_plain_layout_enabled }
|
||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class Tiktok::CallbacksController < ApplicationController
|
|||||||
return handle_ungranted_scopes_error unless all_scopes_granted?
|
return handle_ungranted_scopes_error unless all_scopes_granted?
|
||||||
|
|
||||||
process_successful_authorization
|
process_successful_authorization
|
||||||
|
rescue CustomExceptions::Inbox::LimitExceeded => e
|
||||||
|
handle_limit_error(e)
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
handle_error(e)
|
handle_error(e)
|
||||||
end
|
end
|
||||||
@@ -36,6 +38,14 @@ class Tiktok::CallbacksController < ApplicationController
|
|||||||
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_limit_error(error)
|
||||||
|
redirect_to_error_page(
|
||||||
|
error_type: error.class.name,
|
||||||
|
code: Rack::Utils.status_code(error.http_status),
|
||||||
|
error_message: error.message
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# Handles the case when a user denies permissions or cancels the authorization flow
|
# Handles the case when a user denies permissions or cancels the authorization flow
|
||||||
def handle_authorization_error
|
def handle_authorization_error
|
||||||
redirect_to_error_page(
|
redirect_to_error_page(
|
||||||
|
|||||||
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
|
|||||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||||
return if metadata.blank?
|
return if metadata.blank?
|
||||||
|
|
||||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
Whatsapp::WebhookChannelFinderService.new(
|
||||||
phone_number_id = metadata[:phone_number_id]
|
display_phone_number: metadata[:display_phone_number],
|
||||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
phone_number_id: metadata[:phone_number_id]
|
||||||
|
).perform
|
||||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def normalized_phone_number(phone_number)
|
|
||||||
return if phone_number.blank?
|
|
||||||
|
|
||||||
phone_number = phone_number.to_s
|
|
||||||
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def inactive_whatsapp_number?
|
def inactive_whatsapp_number?
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
class ConversationDrop < BaseDrop
|
class ConversationDrop < BaseDrop
|
||||||
include MessageFormatHelper
|
include MessageFormatHelper
|
||||||
|
|
||||||
|
def id
|
||||||
|
@obj.try(:display_id)
|
||||||
|
end
|
||||||
|
|
||||||
def display_id
|
def display_id
|
||||||
@obj.try(:display_id)
|
@obj.try(:display_id)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,4 +2,18 @@ class InboxDrop < BaseDrop
|
|||||||
def name
|
def name
|
||||||
@obj.try(:name)
|
@obj.try(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def business_name
|
||||||
|
@obj.try(:sanitized_business_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def avatar_url
|
||||||
|
@obj.try(:avatar_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def email
|
||||||
|
return unless @obj.try(:email?)
|
||||||
|
|
||||||
|
@obj.try(:email_address).presence || @obj.try(:channel).try(:email)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
class DataImportErrorFinder
|
||||||
|
RESULTS_LIMIT = 5
|
||||||
|
|
||||||
|
def initialize(data_import)
|
||||||
|
@data_import = data_import
|
||||||
|
end
|
||||||
|
|
||||||
|
def import_errors
|
||||||
|
@data_import.import_errors.non_skip_logs.order(created_at: :desc).limit(RESULTS_LIMIT)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
class DataImportSkipLogFinder
|
||||||
|
RESULTS_LIMIT = 5
|
||||||
|
SOURCE_OBJECT_TYPES = %w[contact conversation message].freeze
|
||||||
|
|
||||||
|
attr_reader :selected_source_object_type
|
||||||
|
|
||||||
|
def initialize(data_import, params = {})
|
||||||
|
@data_import = data_import
|
||||||
|
@selected_source_object_type = valid_source_object_type(params[:skip_logs_type])
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_logs
|
||||||
|
filtered_scope.order(created_at: :desc).limit(RESULTS_LIMIT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def counts_by_type
|
||||||
|
base_scope.group(:source_object_type).count
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def base_scope
|
||||||
|
@base_scope ||= @data_import.import_errors.skip_logs
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_scope
|
||||||
|
return base_scope if selected_source_object_type.blank?
|
||||||
|
|
||||||
|
base_scope.where(source_object_type: selected_source_object_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_source_object_type(source_object_type)
|
||||||
|
source_object_type if SOURCE_OBJECT_TYPES.include?(source_object_type)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -114,10 +114,4 @@ module Api::V1::InboxesHelper
|
|||||||
'sms' => Current.account.sms_channels
|
'sms' => Current.account.sms_channels
|
||||||
}[permitted_params[:channel][:type]]
|
}[permitted_params[:channel][:type]]
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_limit
|
|
||||||
return unless Current.account.inboxes.count >= Current.account.usage_limits[:inboxes]
|
|
||||||
|
|
||||||
render_payment_required('Account limit exceeded. Upgrade to a higher plan')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,4 +22,16 @@ module BillingHelper
|
|||||||
def agents(account)
|
def agents(account)
|
||||||
account.users.count
|
account.users.count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# current_period_end moved to the subscription item in newer Stripe API versions; read both.
|
||||||
|
def subscription_period_end(subscription)
|
||||||
|
subscription['current_period_end'] || subscription['items']['data'].first&.[]('current_period_end')
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscription_ends_on(subscription)
|
||||||
|
period_end = subscription_period_end(subscription)
|
||||||
|
return if period_end.blank?
|
||||||
|
|
||||||
|
Time.zone.at(period_end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ module Filters::FilterHelper
|
|||||||
when 'text_case_insensitive'
|
when 'text_case_insensitive'
|
||||||
text_case_insensitive_filter(query_hash, filter_operator_value)
|
text_case_insensitive_filter(query_hash, filter_operator_value)
|
||||||
else
|
else
|
||||||
|
return text_cast_filter(query_hash, filter_operator_value) if text_search_on_display_id?(query_hash)
|
||||||
|
|
||||||
default_filter(query_hash, filter_operator_value)
|
default_filter(query_hash, filter_operator_value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -82,10 +84,18 @@ module Filters::FilterHelper
|
|||||||
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
"#{filter_operator_value} #{query_hash[:query_operator]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def text_cast_filter(query_hash, filter_operator_value)
|
||||||
|
"(#{filter_config[:table_name]}.#{query_hash[:attribute_key]})::text #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||||
|
end
|
||||||
|
|
||||||
def default_filter(query_hash, filter_operator_value)
|
def default_filter(query_hash, filter_operator_value)
|
||||||
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
"#{filter_config[:table_name]}.#{query_hash[:attribute_key]} #{filter_operator_value} #{query_hash[:query_operator]}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def text_search_on_display_id?(query_hash)
|
||||||
|
query_hash[:attribute_key] == 'display_id' && %w[contains does_not_contain].include?(query_hash[:filter_operator])
|
||||||
|
end
|
||||||
|
|
||||||
def validate_single_condition(condition)
|
def validate_single_condition(condition)
|
||||||
return if condition['query_operator'].nil?
|
return if condition['query_operator'].nil?
|
||||||
return if condition['query_operator'].empty?
|
return if condition['query_operator'].empty?
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
|
|||||||
super('assignable_agents', { accountScoped: true });
|
super('assignable_agents', { accountScoped: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
get(inboxIds) {
|
get(inboxIds, { includeAgentBots = false } = {}) {
|
||||||
return axios.get(this.url, {
|
return axios.get(this.url, {
|
||||||
params: { inbox_ids: inboxIds },
|
params: {
|
||||||
|
inbox_ids: inboxIds,
|
||||||
|
...(includeAgentBots ? { include_agent_bots: true } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class CallsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('calls', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
get(params = {}) {
|
||||||
|
return axios.get(this.url, { params });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CallsAPI();
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
class CaptainAgentSessions extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('captain/agent_sessions', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CaptainAgentSessions();
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
/* global axios */
|
/* global axios */
|
||||||
import ApiClient from '../ApiClient';
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
// Viewer's UTC offset in hours, matching the reports API convention so the
|
||||||
|
// backend can anchor calendar ranges to the viewer's day.
|
||||||
|
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||||
|
|
||||||
class CaptainAssistant extends ApiClient {
|
class CaptainAssistant extends ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('captain/assistants', { accountScoped: true });
|
super('captain/assistants', { accountScoped: true });
|
||||||
@@ -21,6 +25,42 @@ class CaptainAssistant extends ApiClient {
|
|||||||
message_history: messageHistory,
|
message_history: messageHistory,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMetrics({ assistantId, range, signal }) {
|
||||||
|
const requestConfig = {
|
||||||
|
params: { range, timezone_offset: getTimezoneOffset() },
|
||||||
|
};
|
||||||
|
if (signal) requestConfig.signal = signal;
|
||||||
|
|
||||||
|
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFaqStats({ assistantId, signal }) {
|
||||||
|
const requestConfig = {};
|
||||||
|
if (signal) requestConfig.signal = signal;
|
||||||
|
|
||||||
|
return axios.get(`${this.url}/${assistantId}/faq_stats`, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSummary({ assistantId, range, stats }) {
|
||||||
|
return axios.get(`${this.url}/${assistantId}/summary`, {
|
||||||
|
params: { range, timezone_offset: getTimezoneOffset(), stats },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getDrilldown({ assistantId, metric, range, page, signal }) {
|
||||||
|
const requestConfig = {
|
||||||
|
params: {
|
||||||
|
metric,
|
||||||
|
range,
|
||||||
|
timezone_offset: getTimezoneOffset(),
|
||||||
|
page,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (signal) requestConfig.signal = signal;
|
||||||
|
|
||||||
|
return axios.get(`${this.url}/${assistantId}/drilldown`, requestConfig);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainAssistant();
|
export default new CaptainAssistant();
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
class MessageGenerations extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('captain/message_generations', { accountScoped: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new MessageGenerations();
|
|
||||||
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
|
|||||||
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
return axios.get(`${this.url}/${callId}`).then(r => r.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
initiate(conversationId, sdpOffer) {
|
// Either conversationId, or contactId + inboxId to let the BE resolve the conversation.
|
||||||
|
initiate({ conversationId, contactId, inboxId }, sdpOffer) {
|
||||||
return axios
|
return axios
|
||||||
.post(`${this.url}/initiate`, {
|
.post(`${this.url}/initiate`, {
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
|
contact_id: contactId,
|
||||||
|
inbox_id: inboxId,
|
||||||
sdp_offer: sdpOffer,
|
sdp_offer: sdpOffer,
|
||||||
})
|
})
|
||||||
.then(r => r.data);
|
.then(r => r.data);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/* global axios */
|
||||||
|
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class DataImportsAPI extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('data_imports', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
start(id) {
|
||||||
|
return axios.post(`${this.url}/${id}/start`);
|
||||||
|
}
|
||||||
|
|
||||||
|
abandon(id) {
|
||||||
|
return axios.post(`${this.url}/${id}/abandon`);
|
||||||
|
}
|
||||||
|
|
||||||
|
show(id, params = {}) {
|
||||||
|
return axios.get(`${this.url}/${id}`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
validateSource(payload) {
|
||||||
|
return axios.post(`${this.url}/validate_source`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadSkipLogs(id) {
|
||||||
|
return axios.get(`${this.url}/${id}/skip_logs.csv`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadErrorLogs(id) {
|
||||||
|
return axios.get(`${this.url}/${id}/error_logs.csv`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new DataImportsAPI();
|
||||||
@@ -14,6 +14,10 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
return axios.post(`${this.url}subscription`);
|
return axios.post(`${this.url}subscription`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectBillingCurrency(currency) {
|
||||||
|
return axios.post(`${this.url}select_billing_currency`, { currency });
|
||||||
|
}
|
||||||
|
|
||||||
getLimits() {
|
getLimits() {
|
||||||
return axios.get(`${this.url}limits`);
|
return axios.get(`${this.url}limits`);
|
||||||
}
|
}
|
||||||
@@ -27,6 +31,11 @@ class EnterpriseAccountAPI extends ApiClient {
|
|||||||
createTopupCheckout(credits) {
|
createTopupCheckout(credits) {
|
||||||
return axios.post(`${this.url}topup_checkout`, { credits });
|
return axios.post(`${this.url}topup_checkout`, { credits });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Topup packages for the account's billing currency.
|
||||||
|
getTopupOptions() {
|
||||||
|
return axios.get(`${this.url}topup_options`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new EnterpriseAccountAPI();
|
export default new EnterpriseAccountAPI();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class ArticlesAPI extends PortalsAPI {
|
|||||||
categorySlug,
|
categorySlug,
|
||||||
sort,
|
sort,
|
||||||
query,
|
query,
|
||||||
|
signal,
|
||||||
}) {
|
}) {
|
||||||
const url = getArticleSearchURL({
|
const url = getArticleSearchURL({
|
||||||
pageNumber,
|
pageNumber,
|
||||||
@@ -30,7 +31,7 @@ class ArticlesAPI extends PortalsAPI {
|
|||||||
host: this.url,
|
host: this.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
return axios.get(url);
|
return axios.get(url, { signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
searchArticles({ portalSlug, query }) {
|
searchArticles({ portalSlug, query }) {
|
||||||
|
|||||||
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
assignAgent({ conversationId, agentId }) {
|
assignAgent({ conversationId, agentId, assigneeType }) {
|
||||||
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
return axios.post(`${this.url}/${conversationId}/assignments`, {
|
||||||
assignee_id: agentId,
|
assignee_id: agentId,
|
||||||
|
assignee_type: assigneeType,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ describe('#PortalAPI', () => {
|
|||||||
authorId: '1',
|
authorId: '1',
|
||||||
});
|
});
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||||
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1'
|
'/api/v1/portals/room-rental/articles?page=1&locale=en-US&status=published&author_id=1',
|
||||||
|
{ signal: undefined }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#getAssignableAgents with agent bots', () => {
|
||||||
|
assignableAgentsAPI.get([1], { includeAgentBots: true });
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
|
||||||
|
params: {
|
||||||
|
inbox_ids: [1],
|
||||||
|
include_agent_bots: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('#assignAgent', () => {
|
it('#assignAgent', () => {
|
||||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
conversationAPI.assignAgent({
|
||||||
|
conversationId: 12,
|
||||||
|
agentId: 34,
|
||||||
|
assigneeType: 'AgentBot',
|
||||||
|
});
|
||||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||||
`/api/v1/conversations/12/assignments`,
|
`/api/v1/conversations/12/assignments`,
|
||||||
{
|
{
|
||||||
assignee_id: 34,
|
assignee_id: 34,
|
||||||
|
assignee_type: 'AgentBot',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-1
@@ -28,7 +28,12 @@ const inboxes = computed(() => {
|
|||||||
return {
|
return {
|
||||||
name: inbox.name,
|
name: inbox.name,
|
||||||
id: inbox.id,
|
id: inbox.id,
|
||||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
icon: getInboxIconByType(
|
||||||
|
inbox.channelType,
|
||||||
|
inbox.medium,
|
||||||
|
'line',
|
||||||
|
inbox.voiceEnabled
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||||
|
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||||
|
import {
|
||||||
|
VOICE_CALL_DIRECTION,
|
||||||
|
VOICE_CALL_STATUS,
|
||||||
|
} from 'dashboard/components-next/message/constants';
|
||||||
|
import CallStatusBadge from './CallStatusBadge.vue';
|
||||||
|
import { CALL_KIND, getCallKind } from './constants';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
call: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const kind = computed(() => getCallKind(props.call));
|
||||||
|
|
||||||
|
const contactName = computed(() =>
|
||||||
|
(props.call.contact.name || props.call.contact.phoneNumber || '').replace(
|
||||||
|
/^\+/,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentActionLabel = computed(() => {
|
||||||
|
if (!props.call.agent) return '';
|
||||||
|
if (kind.value === CALL_KIND.OUTGOING) return t('CALLS_PAGE.ROW.DIALED_BY');
|
||||||
|
if (kind.value === CALL_KIND.INCOMING) return t('CALLS_PAGE.ROW.PICKED_BY');
|
||||||
|
// Ongoing collapses direction, so resolve dialed-vs-picked from the raw value.
|
||||||
|
if (kind.value === CALL_KIND.ONGOING) {
|
||||||
|
return props.call.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
|
? t('CALLS_PAGE.ROW.DIALED_BY')
|
||||||
|
: t('CALLS_PAGE.ROW.PICKED_BY');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultLabel = computed(() => {
|
||||||
|
if (kind.value === CALL_KIND.MISSED) return t('CALLS_PAGE.ROW.NO_AGENT');
|
||||||
|
if (kind.value === CALL_KIND.NO_REPLY) {
|
||||||
|
return t('CALLS_PAGE.ROW.NO_CONTACT_ANSWER');
|
||||||
|
}
|
||||||
|
if (kind.value === CALL_KIND.FAILED) return t('CALLS_PAGE.ROW.FAILED');
|
||||||
|
if (kind.value === CALL_KIND.ONGOING) {
|
||||||
|
return props.call.status === VOICE_CALL_STATUS.RINGING
|
||||||
|
? t('CALLS_PAGE.ROW.RINGING')
|
||||||
|
: t('CALLS_PAGE.ROW.IN_PROGRESS');
|
||||||
|
}
|
||||||
|
return t('CALLS_PAGE.ROW.ANSWERED');
|
||||||
|
});
|
||||||
|
|
||||||
|
const providerIcon = computed(() =>
|
||||||
|
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdAtLabel = computed(() =>
|
||||||
|
relativeDayTimestamp(props.call.createdAt, t('CALLS_PAGE.ROW.YESTERDAY'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const conversationRoute = computed(() => ({
|
||||||
|
name: 'inbox_conversation',
|
||||||
|
params: {
|
||||||
|
accountId: route.params.accountId,
|
||||||
|
conversation_id: props.call.conversation.displayId,
|
||||||
|
},
|
||||||
|
query: { messageId: props.call.messageId },
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2 py-3.5 border-b border-n-weak lg:hidden">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<Avatar
|
||||||
|
:src="call.contact.avatar"
|
||||||
|
:name="contactName"
|
||||||
|
:size="24"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
|
||||||
|
class="text-heading-3 font-medium truncate text-n-slate-12 min-w-0"
|
||||||
|
>
|
||||||
|
{{ contactName }}
|
||||||
|
</span>
|
||||||
|
<CallStatusBadge :kind="kind" class="ms-auto shrink-0" />
|
||||||
|
<RouterLink
|
||||||
|
:to="conversationRoute"
|
||||||
|
class="inline-flex items-center h-6 gap-1 px-2 text-label-small outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
|
||||||
|
{{ call.conversation.displayId }}
|
||||||
|
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
|
<template v-if="agentActionLabel">
|
||||||
|
<span class="text-label-small text-n-slate-10 shrink-0">
|
||||||
|
{{ agentActionLabel }}
|
||||||
|
</span>
|
||||||
|
<Avatar
|
||||||
|
:src="call.agent.avatar"
|
||||||
|
:name="call.agent.name"
|
||||||
|
:size="20"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
<span class="text-body-main truncate text-n-slate-12 min-w-0">
|
||||||
|
{{ call.agent.name }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-body-main truncate text-n-slate-10 min-w-0">
|
||||||
|
{{ resultLabel }}
|
||||||
|
</span>
|
||||||
|
<span class="w-px h-3 bg-n-strong shrink-0" />
|
||||||
|
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||||
|
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||||
|
{{ call.inbox.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="!call.recordingUrl"
|
||||||
|
class="ms-auto shrink-0 text-label-small text-n-slate-11 tabular-nums"
|
||||||
|
>
|
||||||
|
{{ createdAtLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="call.recordingUrl"
|
||||||
|
class="flex items-center gap-2 min-w-0 justify-between"
|
||||||
|
>
|
||||||
|
<AudioPlayer
|
||||||
|
:src="call.recordingUrl"
|
||||||
|
:fallback-duration="call.durationSeconds || 0"
|
||||||
|
class="flex-1 sm:flex-[0.7] min-w-0"
|
||||||
|
/>
|
||||||
|
<span class="shrink-0 text-label-small text-n-slate-11 tabular-nums">
|
||||||
|
{{ createdAtLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
|
||||||
|
<Avatar
|
||||||
|
:src="call.contact.avatar"
|
||||||
|
:name="contactName"
|
||||||
|
:size="24"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-tooltip.top="{ content: contactName, delay: { show: 500, hide: 0 } }"
|
||||||
|
class="text-heading-3 font-medium truncate text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ contactName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-nowrap items-center gap-x-2 gap-y-2 min-w-0 grow shrink"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||||
|
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||||
|
<div
|
||||||
|
v-if="agentActionLabel"
|
||||||
|
class="gap-x-1.5 min-w-0 flex items-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
|
||||||
|
>
|
||||||
|
{{ agentActionLabel }}
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5 min-w-16 shrink-[20]">
|
||||||
|
<Avatar
|
||||||
|
:src="call.agent.avatar"
|
||||||
|
:name="call.agent.name"
|
||||||
|
:size="20"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: call.agent.name,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
class="text-body-main truncate text-n-slate-12 min-w-0"
|
||||||
|
>
|
||||||
|
{{ call.agent.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
v-else-if="resultLabel"
|
||||||
|
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||||
|
>
|
||||||
|
{{ resultLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AudioPlayer
|
||||||
|
v-if="call.recordingUrl"
|
||||||
|
:src="call.recordingUrl"
|
||||||
|
:fallback-duration="call.durationSeconds || 0"
|
||||||
|
class="w-auto min-w-44 shrink mx-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: call.inbox.name,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
|
||||||
|
>
|
||||||
|
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||||
|
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||||
|
{{ call.inbox.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
:to="conversationRoute"
|
||||||
|
class="inline-flex items-center h-6 gap-1 px-2 text-label-small py-3.5 outline outline-1 -outline-offset-1 rounded-md outline-n-weak text-n-slate-11 hover:bg-n-alpha-1 shrink-0 justify-self-start"
|
||||||
|
>
|
||||||
|
<Icon icon="i-lucide-message-circle" class="size-3.5 text-n-slate-11" />
|
||||||
|
{{ call.conversation.displayId }}
|
||||||
|
<Icon icon="i-lucide-arrow-up-right" class="size-3.5 text-n-slate-11" />
|
||||||
|
</RouterLink>
|
||||||
|
<span
|
||||||
|
v-tooltip.top="{
|
||||||
|
content: createdAtLabel,
|
||||||
|
delay: { show: 500, hide: 0 },
|
||||||
|
}"
|
||||||
|
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
|
||||||
|
>
|
||||||
|
{{ createdAtLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
|
||||||
|
import { downloadFile } from '@chatwoot/utils';
|
||||||
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fallbackDuration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = [1, 1.5, 2];
|
||||||
|
|
||||||
|
const audioPlayer = useTemplateRef('audioPlayer');
|
||||||
|
const { uid } = getCurrentInstance();
|
||||||
|
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const currentTime = ref(0);
|
||||||
|
const duration = ref(props.fallbackDuration);
|
||||||
|
const playbackSpeed = ref(1);
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
const loadedDuration = audioPlayer.value?.duration;
|
||||||
|
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = time => {
|
||||||
|
if (!time || Number.isNaN(time)) return '00:00';
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
|
||||||
|
|
||||||
|
const displayedTime = computed(() =>
|
||||||
|
formatTime(
|
||||||
|
isPlaying.value || currentTime.value ? currentTime.value : duration.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only one recording should play at a time across the list.
|
||||||
|
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||||
|
if (currentPlayingId !== uid && isPlaying.value) {
|
||||||
|
audioPlayer.value?.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const playOrPause = () => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
audioPlayer.value.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
} else {
|
||||||
|
emitter.emit('pause_playing_audio', uid);
|
||||||
|
audioPlayer.value.play();
|
||||||
|
isPlaying.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
currentTime.value = audioPlayer.value?.currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seek = event => {
|
||||||
|
const time = Number(event.target.value);
|
||||||
|
audioPlayer.value.currentTime = time;
|
||||||
|
currentTime.value = time;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
isPlaying.value = false;
|
||||||
|
currentTime.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePlaybackSpeed = () => {
|
||||||
|
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
|
||||||
|
playbackSpeed.value =
|
||||||
|
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
|
||||||
|
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadRecording = () => {
|
||||||
|
downloadFile({ url: props.src, type: 'audio' });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-9 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
ref="audioPlayer"
|
||||||
|
class="hidden"
|
||||||
|
playsinline
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@ended="onEnd"
|
||||||
|
>
|
||||||
|
<source :src="src" />
|
||||||
|
</audio>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="!w-6 !p-0 text-n-slate-12"
|
||||||
|
@click="playOrPause"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon
|
||||||
|
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration || 0"
|
||||||
|
:value="currentTime"
|
||||||
|
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||||
|
@input="seek"
|
||||||
|
/>
|
||||||
|
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||||
|
{{ displayedTime }}
|
||||||
|
</span>
|
||||||
|
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
:label="playbackSpeedLabel"
|
||||||
|
class="!px-1 min-w-6 !text-n-slate-11"
|
||||||
|
@click="changePlaybackSpeed"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="!w-6 !p-0 text-n-slate-11"
|
||||||
|
@click="downloadRecording"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import { CALL_KIND } from './constants';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
kind: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const KIND_CONFIG = {
|
||||||
|
[CALL_KIND.ONGOING]: {
|
||||||
|
icon: 'i-lucide-phone-call',
|
||||||
|
class: 'bg-n-teal-3 text-n-teal-11',
|
||||||
|
},
|
||||||
|
[CALL_KIND.INCOMING]: {
|
||||||
|
icon: 'i-lucide-phone-incoming',
|
||||||
|
class: 'bg-n-slate-3 text-n-slate-11',
|
||||||
|
},
|
||||||
|
[CALL_KIND.OUTGOING]: {
|
||||||
|
icon: 'i-lucide-phone-outgoing',
|
||||||
|
class: 'bg-n-slate-3 text-n-slate-11',
|
||||||
|
},
|
||||||
|
[CALL_KIND.MISSED]: {
|
||||||
|
icon: 'i-lucide-phone-missed',
|
||||||
|
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||||
|
},
|
||||||
|
[CALL_KIND.NO_REPLY]: {
|
||||||
|
icon: 'i-lucide-phone-outgoing',
|
||||||
|
class: 'bg-n-amber-3 text-n-amber-11',
|
||||||
|
},
|
||||||
|
[CALL_KIND.FAILED]: {
|
||||||
|
icon: 'i-lucide-phone-off',
|
||||||
|
class: 'bg-n-ruby-3 text-n-ruby-11',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = computed(() => KIND_CONFIG[props.kind]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center justify-center w-20 gap-1.5 h-6 px-1 rounded-md text-label-small shrink-0"
|
||||||
|
:class="config.class"
|
||||||
|
>
|
||||||
|
<Icon :icon="config.icon" class="size-3 flex-shrink-0" />
|
||||||
|
<span class="truncate">{{
|
||||||
|
t(`CALLS_PAGE.STATUS.${kind.toUpperCase()}`)
|
||||||
|
}}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const setupVoiceChannel = () => {
|
||||||
|
router.push({
|
||||||
|
name: 'settings_inbox_new',
|
||||||
|
params: { accountId: route.params.accountId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EmptyStateLayout
|
||||||
|
:title="t('CALLS_PAGE.SETUP.TITLE')"
|
||||||
|
:subtitle="t('CALLS_PAGE.SETUP.SUBTITLE')"
|
||||||
|
:show-backdrop="false"
|
||||||
|
:action-perms="['administrator']"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button
|
||||||
|
:label="t('CALLS_PAGE.SETUP.ACTION')"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
@click="setupVoiceChannel"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</EmptyStateLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// Null while a fetch is in flight so stale counts are never shown.
|
||||||
|
totalCount: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
inboxes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
// Self-scoped viewers only ever see their own calls, so the assignee filter
|
||||||
|
// is meaningless for them — only admins get it.
|
||||||
|
showAssignee: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activity = defineModel('activity', { type: String, default: null });
|
||||||
|
const assigneeId = defineModel('assigneeId', { type: Number, default: null });
|
||||||
|
const inboxId = defineModel('inboxId', { type: Number, default: null });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const ACTIVITY_ICONS = {
|
||||||
|
missed: 'i-lucide-phone-missed',
|
||||||
|
no_reply: 'i-lucide-phone-outgoing',
|
||||||
|
incoming: 'i-lucide-phone-incoming',
|
||||||
|
outgoing: 'i-lucide-phone-outgoing',
|
||||||
|
in_progress: 'i-lucide-phone-call',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_ACTIVITIES = ['missed', 'no_reply'];
|
||||||
|
const OTHER_ACTIVITIES = ['incoming', 'outgoing', 'in_progress'];
|
||||||
|
|
||||||
|
// A single open-menu identifier keeps the three dropdowns mutually exclusive:
|
||||||
|
// opening one closes the others without any cross-wiring.
|
||||||
|
const openMenu = ref(null); // 'activity' | 'assignee' | 'more' | null
|
||||||
|
|
||||||
|
const toggleMenu = name => {
|
||||||
|
openMenu.value = openMenu.value === name ? null : name;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each dropdown wrapper closes itself on outside clicks. The guard keeps the
|
||||||
|
// other two wrappers (which the click is also outside of) from closing a
|
||||||
|
// menu the user is interacting with.
|
||||||
|
const closeOnOutside = name => {
|
||||||
|
if (openMenu.value === name) openMenu.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activityLabel = value => t(`CALLS_PAGE.FILTERS.${value.toUpperCase()}`);
|
||||||
|
|
||||||
|
const activeChipLabel = computed(() => {
|
||||||
|
const label = activityLabel(activity.value);
|
||||||
|
return props.totalCount === null ? label : `${label} (${props.totalCount})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inactiveChips = computed(() =>
|
||||||
|
BASE_ACTIVITIES.filter(value => value !== activity.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherActivityItems = computed(() =>
|
||||||
|
OTHER_ACTIVITIES.map(value => ({
|
||||||
|
label: activityLabel(value),
|
||||||
|
value,
|
||||||
|
action: 'filter',
|
||||||
|
icon: ACTIVITY_ICONS[value],
|
||||||
|
isSelected: activity.value === value,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const assigneeItems = computed(() => [
|
||||||
|
{
|
||||||
|
label: t('CALLS_PAGE.FILTERS.ALL_ASSIGNEES'),
|
||||||
|
value: null,
|
||||||
|
action: 'filter',
|
||||||
|
isSelected: !assigneeId.value,
|
||||||
|
},
|
||||||
|
...props.agents.map(agent => ({
|
||||||
|
label: agent.name,
|
||||||
|
value: agent.id,
|
||||||
|
action: 'filter',
|
||||||
|
thumbnail: { name: agent.name, src: agent.thumbnail },
|
||||||
|
isSelected: assigneeId.value === agent.id,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moreFiltersSections = computed(() => [
|
||||||
|
{
|
||||||
|
title: t('CALLS_PAGE.FILTERS.INBOX'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: t('CALLS_PAGE.FILTERS.ALL_INBOXES'),
|
||||||
|
value: null,
|
||||||
|
action: 'inbox',
|
||||||
|
isSelected: !inboxId.value,
|
||||||
|
},
|
||||||
|
...props.inboxes.map(inbox => ({
|
||||||
|
label: inbox.name,
|
||||||
|
value: inbox.id,
|
||||||
|
action: 'inbox',
|
||||||
|
isSelected: inboxId.value === inbox.id,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selectedAssignee = computed(
|
||||||
|
() => props.agents.find(agent => agent.id === assigneeId.value) || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedAssigneeLabel = computed(
|
||||||
|
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOtherActivitySelected = computed(() =>
|
||||||
|
OTHER_ACTIVITIES.includes(activity.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||||
|
|
||||||
|
const setActivity = value => {
|
||||||
|
openMenu.value = null;
|
||||||
|
activity.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAssignee = ({ value }) => {
|
||||||
|
openMenu.value = null;
|
||||||
|
assigneeId.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMoreFilter = ({ action, value }) => {
|
||||||
|
openMenu.value = null;
|
||||||
|
if (action === 'inbox') inboxId.value = value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||||
|
{{
|
||||||
|
totalCount === null
|
||||||
|
? t('CALLS_PAGE.ALL_CALLS')
|
||||||
|
: t('CALLS_PAGE.ALL_CALLS_COUNT', { count: totalCount })
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
variant="outline"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
:icon="ACTIVITY_ICONS[activity]"
|
||||||
|
class="shrink-0 !h-7 !px-2"
|
||||||
|
@click="setActivity(null)"
|
||||||
|
>
|
||||||
|
{{ activeChipLabel }}
|
||||||
|
<Icon icon="i-lucide-x" />
|
||||||
|
</Button>
|
||||||
|
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
|
||||||
|
<Button
|
||||||
|
v-for="chip in inactiveChips"
|
||||||
|
:key="chip"
|
||||||
|
variant="outline"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
:icon="ACTIVITY_ICONS[chip]"
|
||||||
|
:label="activityLabel(chip)"
|
||||||
|
class="shrink-0 text-n-slate-11 !h-7 !px-2"
|
||||||
|
@click="setActivity(chip)"
|
||||||
|
/>
|
||||||
|
<OnClickOutside
|
||||||
|
class="relative shrink-0"
|
||||||
|
@trigger="closeOnOutside('activity')"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-phone"
|
||||||
|
class="!h-7 !px-2"
|
||||||
|
:class="
|
||||||
|
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
|
||||||
|
"
|
||||||
|
@click="toggleMenu('activity')"
|
||||||
|
>
|
||||||
|
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||||
|
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="openMenu === 'activity'"
|
||||||
|
:menu-items="otherActivityItems"
|
||||||
|
class="mt-1 start-0 top-full w-44"
|
||||||
|
@action="setActivity($event.value)"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<OnClickOutside
|
||||||
|
v-if="showAssignee"
|
||||||
|
class="relative"
|
||||||
|
@trigger="closeOnOutside('assignee')"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
icon="i-woot-empty-assignee"
|
||||||
|
class="max-w-52 !h-7 !px-2"
|
||||||
|
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||||
|
@click="toggleMenu('assignee')"
|
||||||
|
>
|
||||||
|
<template v-if="selectedAssignee" #icon>
|
||||||
|
<Avatar
|
||||||
|
:src="selectedAssignee.thumbnail"
|
||||||
|
:name="selectedAssignee.name"
|
||||||
|
:size="16"
|
||||||
|
rounded-full
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||||
|
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="openMenu === 'assignee'"
|
||||||
|
:menu-items="assigneeItems"
|
||||||
|
show-search
|
||||||
|
class="mt-1 end-0 top-full w-56 max-h-72"
|
||||||
|
@action="setAssignee"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
<OnClickOutside class="relative" @trigger="closeOnOutside('more')">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon="i-lucide-list-filter"
|
||||||
|
class="!h-7 !px-2"
|
||||||
|
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||||
|
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||||
|
@click="toggleMenu('more')"
|
||||||
|
>
|
||||||
|
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-chevron-down"
|
||||||
|
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="openMenu === 'more'"
|
||||||
|
:menu-sections="moreFiltersSections"
|
||||||
|
class="mt-1 end-0 top-full w-56 max-h-80"
|
||||||
|
@action="applyMoreFilter"
|
||||||
|
/>
|
||||||
|
</OnClickOutside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
VOICE_CALL_STATUS,
|
||||||
|
VOICE_CALL_DIRECTION,
|
||||||
|
} from 'dashboard/components-next/message/constants';
|
||||||
|
|
||||||
|
export const CALL_KIND = {
|
||||||
|
ONGOING: 'ongoing',
|
||||||
|
INCOMING: 'incoming',
|
||||||
|
OUTGOING: 'outgoing',
|
||||||
|
MISSED: 'missed',
|
||||||
|
NO_REPLY: 'no_reply',
|
||||||
|
FAILED: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
// The API returns display values: status (ringing/in-progress/completed/
|
||||||
|
// no-answer/failed) and direction (inbound/outbound). The list UI presents
|
||||||
|
// them as a single "kind" per row.
|
||||||
|
export const getCallKind = call => {
|
||||||
|
if (
|
||||||
|
[VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes(
|
||||||
|
call.status
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return CALL_KIND.ONGOING;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
[VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status)
|
||||||
|
) {
|
||||||
|
return CALL_KIND.FAILED;
|
||||||
|
}
|
||||||
|
const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND;
|
||||||
|
if (call.status === VOICE_CALL_STATUS.NO_ANSWER) {
|
||||||
|
return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY;
|
||||||
|
}
|
||||||
|
return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter chips map to the status/direction params supported by CallFinder.
|
||||||
|
export const CALL_ACTIVITY_PARAMS = {
|
||||||
|
missed: {
|
||||||
|
status: VOICE_CALL_STATUS.NO_ANSWER,
|
||||||
|
direction: VOICE_CALL_DIRECTION.INBOUND,
|
||||||
|
},
|
||||||
|
no_reply: {
|
||||||
|
status: VOICE_CALL_STATUS.NO_ANSWER,
|
||||||
|
direction: VOICE_CALL_DIRECTION.OUTBOUND,
|
||||||
|
},
|
||||||
|
incoming: { direction: VOICE_CALL_DIRECTION.INBOUND },
|
||||||
|
outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND },
|
||||||
|
in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS },
|
||||||
|
};
|
||||||
@@ -83,8 +83,12 @@ const campaignStatus = computed(() => {
|
|||||||
const inboxName = computed(() => props.inbox?.name || '');
|
const inboxName = computed(() => props.inbox?.name || '');
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { medium, channel_type: type } = props.inbox;
|
const {
|
||||||
return getInboxIconByType(type, medium);
|
medium,
|
||||||
|
channel_type: type,
|
||||||
|
voice_enabled: voiceEnabled,
|
||||||
|
} = props.inbox;
|
||||||
|
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||||
import ContactAPI from 'dashboard/api/contacts';
|
|
||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
|
|||||||
|
|
||||||
const whatsappCallSession = useWhatsappCallSession();
|
const whatsappCallSession = useWhatsappCallSession();
|
||||||
|
|
||||||
// Find the most recent open conversation for this contact in the picked inbox.
|
|
||||||
// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path).
|
|
||||||
// Pass inboxId so the BE applies the filter before the 20-row cap — without it,
|
|
||||||
// contacts whose latest WhatsApp conversation falls outside the 20 most recent
|
|
||||||
// across all inboxes would be treated as having no conversation.
|
|
||||||
const findWhatsappConversationId = async inboxId => {
|
|
||||||
const { data } = await ContactAPI.getConversations(props.contactId, {
|
|
||||||
inboxId,
|
|
||||||
});
|
|
||||||
const conversations = data?.payload || [];
|
|
||||||
const match = [...conversations].sort(
|
|
||||||
(a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0)
|
|
||||||
)[0];
|
|
||||||
return match?.id || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
const startWhatsappCall = async (inboxId, conversationIdHint) => {
|
||||||
// WhatsApp /initiate is conversation-scoped, so we must hand it a
|
const response = await whatsappCallSession.initiateOutboundCall(
|
||||||
// conversation. Use the caller's hint when given (in-conversation flow);
|
conversationIdHint
|
||||||
// otherwise pick the most recent one in the inbox.
|
? { conversationId: conversationIdHint }
|
||||||
const conversationId =
|
: { contactId: props.contactId, inboxId }
|
||||||
conversationIdHint || (await findWhatsappConversationId(inboxId));
|
);
|
||||||
if (!conversationId) {
|
|
||||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response =
|
|
||||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
|
||||||
// The composable returns { status: 'locked' } when an init is already in
|
// The composable returns { status: 'locked' } when an init is already in
|
||||||
// flight or a call is already active; treat that as a soft no-op rather than
|
// flight or a call is already active; treat that as a soft no-op rather than
|
||||||
// claiming success.
|
// claiming success.
|
||||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||||
|
|
||||||
|
const conversationId = response?.conversation_id || conversationIdHint;
|
||||||
if (!response?.id) {
|
if (!response?.id) {
|
||||||
// Permission template path returns no call id. Mirror the header button and
|
// Permission template path returns no call id. Mirror the header button and
|
||||||
// surface whether the request was just sent or is already pending instead of
|
// surface whether the request was just sent or is already pending instead of
|
||||||
|
|||||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
|||||||
const inboxName = computed(() => inbox.value?.name);
|
const inboxName = computed(() => inbox.value?.name);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { channelType, medium } = inbox.value;
|
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium);
|
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastActivityAt = computed(() => {
|
const lastActivityAt = computed(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, useTemplateRef } from 'vue';
|
||||||
import { useToggle } from '@vueuse/core';
|
import { useToggle } from '@vueuse/core';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
ARTICLE_MENU_ITEMS,
|
ARTICLE_MENU_ITEMS,
|
||||||
ARTICLE_MENU_OPTIONS,
|
ARTICLE_MENU_OPTIONS,
|
||||||
ARTICLE_STATUSES,
|
ARTICLE_STATUSES,
|
||||||
|
getArticleStatus,
|
||||||
} from 'dashboard/helper/portalHelper';
|
} from 'dashboard/helper/portalHelper';
|
||||||
|
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
|
||||||
|
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
@@ -53,6 +55,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
hasPendingChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
selectable: {
|
selectable: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -68,12 +74,16 @@ const emit = defineEmits([
|
|||||||
'articleAction',
|
'articleAction',
|
||||||
'toggleSelect',
|
'toggleSelect',
|
||||||
'hover',
|
'hover',
|
||||||
|
'draftResolved',
|
||||||
|
'draftFailed',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
|
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
|
||||||
|
|
||||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
'accounts/isFeatureEnabledonAccount'
|
'accounts/isFeatureEnabledonAccount'
|
||||||
@@ -105,7 +115,18 @@ const articleMenuItems = computed(() => {
|
|||||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||||
.map(key => commonItems[key]);
|
.map(key => commonItems[key]);
|
||||||
|
|
||||||
return [...statusItems, commonItems.delete];
|
const draftItems = props.hasPendingChanges
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
|
||||||
|
value: 'discard-draft',
|
||||||
|
action: 'discard-draft',
|
||||||
|
icon: 'i-lucide-undo-2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return [...statusItems, ...draftItems, commonItems.delete];
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusTextColor = computed(() => {
|
const statusTextColor = computed(() => {
|
||||||
@@ -153,6 +174,12 @@ const lastUpdatedAt = computed(() => {
|
|||||||
|
|
||||||
const handleArticleAction = ({ action, value }) => {
|
const handleArticleAction = ({ action, value }) => {
|
||||||
toggleDropdown(false);
|
toggleDropdown(false);
|
||||||
|
// Un-publishing an article with staged edits — confirm apply/discard first;
|
||||||
|
// the popover applies the chosen status itself.
|
||||||
|
if (props.hasPendingChanges && (action === 'draft' || action === 'archive')) {
|
||||||
|
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit('articleAction', { action, value, id: props.id });
|
emit('articleAction', { action, value, id: props.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,6 +211,18 @@ const handleClick = id => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
v-if="hasPendingChanges"
|
||||||
|
:title="
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS_TOOLTIP'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="text-xs font-medium inline-flex items-center gap-1 h-6 px-2 py-0.5 rounded-md text-n-slate-11 bg-n-alpha-2 whitespace-nowrap shrink-0"
|
||||||
|
>
|
||||||
|
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
|
||||||
|
{{ t('HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.PENDING_EDITS') }}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||||
:class="statusTextColor"
|
:class="statusTextColor"
|
||||||
@@ -204,9 +243,15 @@ const handleClick = id => {
|
|||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
v-if="showActionsDropdown"
|
v-if="showActionsDropdown"
|
||||||
:menu-items="articleMenuItems"
|
:menu-items="articleMenuItems"
|
||||||
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:left-0 xl:rtl:right-0 top-full"
|
class="mt-1 end-0 top-full w-40"
|
||||||
@action="handleArticleAction($event)"
|
@action="handleArticleAction($event)"
|
||||||
/>
|
/>
|
||||||
|
<ArticlePendingChangesPopover
|
||||||
|
ref="pendingChangesPopoverRef"
|
||||||
|
:article-id="id"
|
||||||
|
@resolved="emit('draftResolved', $event)"
|
||||||
|
@failed="emit('draftFailed', $event)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ const localeMenuLabels = computed(() => ({
|
|||||||
'customize-content': t(
|
'customize-content': t(
|
||||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
|
||||||
),
|
),
|
||||||
|
'select-popular-content': t(
|
||||||
|
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.SELECT_POPULAR_CONTENT'
|
||||||
|
),
|
||||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import MessageFormatter from 'shared/helpers/MessageFormatter';
|
||||||
|
import {
|
||||||
|
renderInlineDiff,
|
||||||
|
buildDiffBlocks,
|
||||||
|
} from 'dashboard/helper/articleDiffHelper';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
article: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOpen = defineModel({ type: Boolean, default: false });
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const liveTitle = computed(() => props.article?.title ?? '');
|
||||||
|
const liveContent = computed(() => props.article?.content ?? '');
|
||||||
|
const draftTitle = computed(() => props.article?.draftTitle ?? liveTitle.value);
|
||||||
|
const draftContent = computed(
|
||||||
|
() => props.article?.draftContent ?? liveContent.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleChanged = computed(() => liveTitle.value !== draftTitle.value);
|
||||||
|
const titleDiff = computed(() =>
|
||||||
|
renderInlineDiff(liveTitle.value, draftTitle.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const contentBlocks = computed(() =>
|
||||||
|
buildDiffBlocks(liveContent.value, draftContent.value)
|
||||||
|
);
|
||||||
|
const contentChanged = computed(() =>
|
||||||
|
contentBlocks.value.some(block => block.type !== 'equal')
|
||||||
|
);
|
||||||
|
|
||||||
|
// HC tables store per-column widths (px, 0 = unset) in this marker, which the
|
||||||
|
// formatter strips. Re-apply them as a fixed-layout <colgroup>, defaulting
|
||||||
|
// unsized columns so they don't collapse.
|
||||||
|
const COLWIDTHS_RE = /<!--cw-colwidths:([\d,]+)-->/;
|
||||||
|
const DEFAULT_COL_WIDTH = 50;
|
||||||
|
|
||||||
|
const applyColumnWidths = (html, widths) => {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const table = doc.body.querySelector('table');
|
||||||
|
if (!table) return html;
|
||||||
|
|
||||||
|
const sized = widths.map(width => (width > 0 ? width : DEFAULT_COL_WIDTH));
|
||||||
|
const colgroup = doc.createElement('colgroup');
|
||||||
|
sized.forEach(width => {
|
||||||
|
const col = doc.createElement('col');
|
||||||
|
col.style.width = `${width}px`;
|
||||||
|
colgroup.appendChild(col);
|
||||||
|
});
|
||||||
|
table.insertBefore(colgroup, table.firstChild);
|
||||||
|
|
||||||
|
table.style.tableLayout = 'fixed';
|
||||||
|
table.style.width = `${sized.reduce((sum, width) => sum + width, 0)}px`;
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderMarkdown = markdown => {
|
||||||
|
if (!markdown) return '';
|
||||||
|
const html = new MessageFormatter(markdown).formattedMessage;
|
||||||
|
const match = markdown.match(COLWIDTHS_RE);
|
||||||
|
return match
|
||||||
|
? applyColumnWidths(html, match[1].split(',').map(Number))
|
||||||
|
: html;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockClass = type => {
|
||||||
|
if (type === 'added') {
|
||||||
|
return 'border-n-teal-9 bg-n-teal-2';
|
||||||
|
}
|
||||||
|
if (type === 'removed') {
|
||||||
|
return 'border-n-ruby-9 bg-n-ruby-2 line-through decoration-n-ruby-9/50';
|
||||||
|
}
|
||||||
|
return 'border-transparent';
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissOnClickOutside = [close, { ignore: ['[data-diff-toggle]'] }];
|
||||||
|
|
||||||
|
useKeyboardEvents({ Escape: { action: close, allowOnFocusedInput: true } });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TeleportWithDirection to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||||
|
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||||
|
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||||
|
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||||
|
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||||
|
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
v-if="isOpen"
|
||||||
|
v-on-click-outside="dismissOnClickOutside"
|
||||||
|
class="fixed inset-y-0 z-40 flex flex-col w-full shadow-2xl end-0 max-w-lg bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="flex items-start justify-between gap-3 px-6 py-4 border-b shrink-0 border-n-weak bg-n-solid-1"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="size-2 rounded-full bg-n-amber-9 shrink-0" />
|
||||||
|
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||||
|
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE') }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
class="shrink-0 hover:text-n-slate-11"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col flex-1 min-h-0 gap-4 px-6 pt-4 pb-6 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<div
|
||||||
|
v-if="titleChanged"
|
||||||
|
class="flex flex-col gap-1.5 border-s-[3px] border-transparent ps-3"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-medium tracking-wide uppercase text-n-slate-10"
|
||||||
|
>
|
||||||
|
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.DIFF_DIALOG.TITLE_LABEL') }}
|
||||||
|
</span>
|
||||||
|
<h1
|
||||||
|
class="text-lg font-semibold leading-snug text-n-slate-12"
|
||||||
|
v-html="titleDiff"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="contentChanged"
|
||||||
|
class="flex flex-col gap-1 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_td]:border [&_th]:border-n-weak [&_td]:border-n-weak [&_th]:p-2 [&_td]:p-2 [&_th]:bg-n-alpha-1 [&_th]:text-start [&_td]:align-top"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(block, index) in contentBlocks"
|
||||||
|
:key="index"
|
||||||
|
class="px-3 py-1.5 overflow-x-auto text-sm leading-relaxed break-words border-s-[3px] rounded-e-md text-n-slate-12 prose-sm prose dark:prose-invert max-w-none [&_p]:my-0 [&>:first-child]:mt-0 [&>:last-child]:mb-0"
|
||||||
|
:class="blockClass(block.type)"
|
||||||
|
v-html="renderMarkdown(block.md)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</Transition>
|
||||||
|
</TeleportWithDirection>
|
||||||
|
</template>
|
||||||
+65
-17
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { useTimeoutFn } from '@vueuse/core';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
import { ARTICLE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||||
|
|
||||||
@@ -9,6 +9,7 @@ import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
|||||||
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
import FullEditor from 'dashboard/components/widgets/WootWriter/FullEditor.vue';
|
||||||
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
|
import ArticleEditorHeader from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue';
|
||||||
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
|
import ArticleEditorControls from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorControls.vue';
|
||||||
|
import ArticleDiffPanel from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleDiffPanel.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
article: {
|
article: {
|
||||||
@@ -38,32 +39,75 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const isNewArticle = computed(() => !props.article?.id);
|
const isNewArticle = computed(() => !props.article?.id);
|
||||||
|
|
||||||
const localTitle = ref(props.article?.title ?? '');
|
// Prefer the draft; `??` keeps a deliberately-cleared empty string instead of
|
||||||
const localContent = ref(props.article?.content ?? '');
|
// falling back to the live value.
|
||||||
|
const effectiveTitle = () =>
|
||||||
|
props.article?.draftTitle ?? props.article?.title ?? '';
|
||||||
|
const effectiveContent = () =>
|
||||||
|
props.article?.draftContent ?? props.article?.content ?? '';
|
||||||
|
|
||||||
// Sync local state when navigating to a different article or on initial fetch
|
const hasPendingChanges = computed(
|
||||||
|
() => props.article?.draftTitle != null || props.article?.draftContent != null
|
||||||
|
);
|
||||||
|
|
||||||
|
const localTitle = ref(effectiveTitle());
|
||||||
|
const localContent = ref(effectiveContent());
|
||||||
|
|
||||||
|
const isDiffPanelOpen = ref(false);
|
||||||
|
|
||||||
|
// Autosave 500ms after the last edit. It sends both title and content so an
|
||||||
|
// edit to one never drops a recent edit to the other. `stop` cancels a queued
|
||||||
|
// save; `isPending` tells the header to wait before allowing a publish.
|
||||||
|
const {
|
||||||
|
isPending: isSaving,
|
||||||
|
start: debouncedSave,
|
||||||
|
stop: cancelSave,
|
||||||
|
} = useTimeoutFn(
|
||||||
|
() =>
|
||||||
|
emit('saveArticle', {
|
||||||
|
title: localTitle.value,
|
||||||
|
content: localContent.value,
|
||||||
|
}),
|
||||||
|
500,
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncLocalState = () => {
|
||||||
|
cancelSave();
|
||||||
|
localTitle.value = effectiveTitle();
|
||||||
|
localContent.value = effectiveContent();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reseed on article switch or once a draft is published/discarded; close the
|
||||||
|
// diff panel in the latter case since there's nothing left to compare.
|
||||||
watch(
|
watch(
|
||||||
() => props.article?.id,
|
[() => props.article?.id, hasPendingChanges],
|
||||||
newId => {
|
([id, pending], [prevId, prevPending]) => {
|
||||||
if (newId) {
|
if ((id && id !== prevId) || (prevPending && !pending)) syncLocalState();
|
||||||
localTitle.value = props.article?.title ?? '';
|
if (prevPending && !pending) isDiffPanelOpen.value = false;
|
||||||
localContent.value = props.article?.content ?? '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedSave = debounce(value => emit('saveArticle', value), 500, false);
|
const scheduleSave = () => {
|
||||||
|
|
||||||
const handleSave = value => {
|
|
||||||
if (isNewArticle.value) return;
|
if (isNewArticle.value) return;
|
||||||
debouncedSave(value);
|
debouncedSave();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Flush a queued save on unmount so leaving the editor doesn't drop the last edit.
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (isNewArticle.value || !isSaving.value) return;
|
||||||
|
cancelSave();
|
||||||
|
emit('saveArticle', {
|
||||||
|
title: localTitle.value,
|
||||||
|
content: localContent.value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const articleTitle = computed({
|
const articleTitle = computed({
|
||||||
get: () => localTitle.value,
|
get: () => localTitle.value,
|
||||||
set: value => {
|
set: value => {
|
||||||
localTitle.value = value;
|
localTitle.value = value;
|
||||||
handleSave({ title: value });
|
scheduleSave();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +115,7 @@ const articleContent = computed({
|
|||||||
get: () => localContent.value,
|
get: () => localContent.value,
|
||||||
set: content => {
|
set: content => {
|
||||||
localContent.value = content;
|
localContent.value = content;
|
||||||
handleSave({ content });
|
scheduleSave();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,9 +152,13 @@ const handleCreateArticle = event => {
|
|||||||
:is-saved="isSaved"
|
:is-saved="isSaved"
|
||||||
:status="article.status"
|
:status="article.status"
|
||||||
:article-id="article.id"
|
:article-id="article.id"
|
||||||
|
:pending-changes="hasPendingChanges"
|
||||||
|
:is-saving="isSaving"
|
||||||
@go-back="onClickGoBack"
|
@go-back="onClickGoBack"
|
||||||
@preview-article="previewArticle"
|
@preview-article="previewArticle"
|
||||||
|
@show-diff="isDiffPanelOpen = !isDiffPanelOpen"
|
||||||
/>
|
/>
|
||||||
|
<ArticleDiffPanel v-model="isDiffPanelOpen" :article="article" />
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
<div class="flex flex-col gap-3 pl-4 mb-3 rtl:pr-3 rtl:pl-0">
|
||||||
|
|||||||
+165
-14
@@ -1,8 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, useTemplateRef } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useStore } from 'dashboard/composables/store.js';
|
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
@@ -17,6 +17,7 @@ import wootConstants from 'dashboard/constants/globals';
|
|||||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import ArticlePendingChangesPopover from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticlePendingChangesPopover.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
isUpdating: {
|
isUpdating: {
|
||||||
@@ -35,9 +36,17 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
|
pendingChanges: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
isSaving: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['goBack', 'previewArticle']);
|
const emit = defineEmits(['goBack', 'previewArticle', 'showDiff']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -49,9 +58,30 @@ const { ARTICLE_STATUS_TYPES } = wootConstants;
|
|||||||
|
|
||||||
const showArticleActionMenu = ref(false);
|
const showArticleActionMenu = ref(false);
|
||||||
|
|
||||||
|
const pendingChangesPopoverRef = useTemplateRef('pendingChangesPopoverRef');
|
||||||
|
|
||||||
|
// Per-article update flag the store already maintains.
|
||||||
|
const articleUiFlags = useMapGetter('articles/uiFlags');
|
||||||
|
const isUpdatingArticle = computed(
|
||||||
|
() => articleUiFlags.value(props.articleId).isUpdating
|
||||||
|
);
|
||||||
|
|
||||||
|
// Publishing while a save is still in flight would promote a stale draft, so we show an alert
|
||||||
|
const blockedWhileSaving = () => {
|
||||||
|
if (!props.isSaving && !isUpdatingArticle.value) return false;
|
||||||
|
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.SAVE_IN_PROGRESS'));
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPublished = computed(() => props.status === ARTICLE_STATUSES.PUBLISHED);
|
||||||
|
|
||||||
|
const hasPendingChanges = computed(
|
||||||
|
() => isPublished.value && props.pendingChanges
|
||||||
|
);
|
||||||
|
|
||||||
const articleMenuItems = computed(() => {
|
const articleMenuItems = computed(() => {
|
||||||
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
|
const statusOptions = ARTICLE_EDITOR_STATUS_OPTIONS[props.status] ?? [];
|
||||||
return statusOptions.map(option => {
|
const items = statusOptions.map(option => {
|
||||||
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
|
const { label, value, icon } = ARTICLE_MENU_ITEMS[option];
|
||||||
return {
|
return {
|
||||||
label: t(label),
|
label: t(label),
|
||||||
@@ -60,6 +90,17 @@ const articleMenuItems = computed(() => {
|
|||||||
icon,
|
icon,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasPendingChanges.value) {
|
||||||
|
items.push({
|
||||||
|
label: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES'),
|
||||||
|
value: 'discard-draft',
|
||||||
|
action: 'discard-draft',
|
||||||
|
icon: 'i-lucide-undo-2',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusText = computed(() =>
|
const statusText = computed(() =>
|
||||||
@@ -85,8 +126,9 @@ const getStatusMessage = (status, isSuccess) => {
|
|||||||
: '';
|
: '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateArticleStatus = async ({ value }) => {
|
// Pass draftAction (publishDraft/discardDraft) to resolve a draft in the same
|
||||||
showArticleActionMenu.value = false;
|
// update; omit it for a plain status change.
|
||||||
|
const performStatusUpdate = async (value, draftAction) => {
|
||||||
const status = getArticleStatus(value);
|
const status = getArticleStatus(value);
|
||||||
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||||
isArticlePublishing.value = true;
|
isArticlePublishing.value = true;
|
||||||
@@ -94,7 +136,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
const { portalSlug } = route.params;
|
const { portalSlug } = route.params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.dispatch('articles/update', {
|
await store.dispatch(`articles/${draftAction ?? 'update'}`, {
|
||||||
portalSlug,
|
portalSlug,
|
||||||
articleId: props.articleId,
|
articleId: props.articleId,
|
||||||
status,
|
status,
|
||||||
@@ -107,12 +149,100 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||||
}
|
}
|
||||||
isArticlePublishing.value = false;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(error?.message ?? getStatusMessage(status, false));
|
useAlert(error?.message ?? getStatusMessage(status, false));
|
||||||
|
} finally {
|
||||||
isArticlePublishing.value = false;
|
isArticlePublishing.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateArticleStatus = ({ value }) => {
|
||||||
|
showArticleActionMenu.value = false;
|
||||||
|
// Leaving published with unsaved draft edits — ask whether to apply or discard
|
||||||
|
// first; the popover applies the status itself once resolved.
|
||||||
|
if (hasPendingChanges.value) {
|
||||||
|
pendingChangesPopoverRef.value?.open(getArticleStatus(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
performStatusUpdate(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishDraftChanges = async () => {
|
||||||
|
isArticlePublishing.value = true;
|
||||||
|
const { portalSlug } = route.params;
|
||||||
|
try {
|
||||||
|
await store.dispatch('articles/publishDraft', {
|
||||||
|
portalSlug,
|
||||||
|
articleId: props.articleId,
|
||||||
|
});
|
||||||
|
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_SUCCESS'));
|
||||||
|
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error?.message ??
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isArticlePublishing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const discardDraftChanges = async () => {
|
||||||
|
const { portalSlug } = route.params;
|
||||||
|
try {
|
||||||
|
await store.dispatch('articles/discardDraft', {
|
||||||
|
portalSlug,
|
||||||
|
articleId: props.articleId,
|
||||||
|
});
|
||||||
|
useAlert(t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
error?.message ??
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPrimaryAction = () => {
|
||||||
|
if (blockedWhileSaving()) return;
|
||||||
|
if (hasPendingChanges.value) {
|
||||||
|
publishDraftChanges();
|
||||||
|
} else if (props.pendingChanges) {
|
||||||
|
// Promote leftover draft edits on publish instead of republishing stale content.
|
||||||
|
performStatusUpdate(ARTICLE_STATUSES.PUBLISHED, 'publishDraft');
|
||||||
|
} else {
|
||||||
|
updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMenuAction = event => {
|
||||||
|
showArticleActionMenu.value = false;
|
||||||
|
// Don't resolve a draft while an autosave is still in flight — it could land
|
||||||
|
// after and recreate the draft we just discarded/applied.
|
||||||
|
if (blockedWhileSaving()) return;
|
||||||
|
if (event.action === 'discard-draft') {
|
||||||
|
discardDraftChanges();
|
||||||
|
} else {
|
||||||
|
updateArticleStatus(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The popover applies the draft + status itself; we just surface the outcome.
|
||||||
|
const onDraftResolved = status => {
|
||||||
|
useAlert(getStatusMessage(status, true));
|
||||||
|
if (status === ARTICLE_STATUS_TYPES.ARCHIVE) {
|
||||||
|
useTrack(PORTALS_EVENTS.ARCHIVE_ARTICLE, { uiFrom: 'header' });
|
||||||
|
} else if (status === ARTICLE_STATUS_TYPES.PUBLISH) {
|
||||||
|
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDraftFailed = error => {
|
||||||
|
useAlert(
|
||||||
|
error?.message ??
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||||
|
);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -127,13 +257,24 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
@click="onClickGoBack"
|
@click="onClickGoBack"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
v-if="hasPendingChanges"
|
||||||
|
type="button"
|
||||||
|
data-diff-toggle
|
||||||
|
:title="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.VIEW_CHANGES')"
|
||||||
|
class="flex items-center gap-1.5 px-2 py-1 text-xs font-medium transition-colors rounded-lg cursor-pointer text-n-amber-11 bg-n-amber-3 outline outline-1 outline-n-amber-5 hover:bg-n-amber-4"
|
||||||
|
@click="emit('showDiff')"
|
||||||
|
>
|
||||||
|
<span class="rounded-full size-1.5 bg-n-amber-9 shrink-0" />
|
||||||
|
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PENDING_CHANGES') }}
|
||||||
|
</button>
|
||||||
<span
|
<span
|
||||||
v-if="isUpdating || isSaved"
|
v-if="isUpdating || isSaved"
|
||||||
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
|
class="text-xs font-medium transition-all duration-300 text-n-slate-11"
|
||||||
>
|
>
|
||||||
{{ statusText }}
|
{{ statusText }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
<div class="relative flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
|
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PREVIEW')"
|
||||||
color="slate"
|
color="slate"
|
||||||
@@ -143,17 +284,21 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
/>
|
/>
|
||||||
<ButtonGroup class="flex items-center">
|
<ButtonGroup class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
:label="
|
||||||
|
hasPendingChanges
|
||||||
|
? t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES')
|
||||||
|
: t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')
|
||||||
|
"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||||
no-animation
|
no-animation
|
||||||
:is-loading="isArticlePublishing"
|
:is-loading="isArticlePublishing"
|
||||||
:disabled="
|
:disabled="
|
||||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
|
||||||
!articleId ||
|
!articleId ||
|
||||||
isArticlePublishing
|
isArticlePublishing ||
|
||||||
|
(isPublished && !hasPendingChanges)
|
||||||
"
|
"
|
||||||
@click="updateArticleStatus({ value: ARTICLE_STATUSES.PUBLISHED })"
|
@click="onPrimaryAction"
|
||||||
/>
|
/>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<OnClickOutside @trigger="showArticleActionMenu = false">
|
<OnClickOutside @trigger="showArticleActionMenu = false">
|
||||||
@@ -169,11 +314,17 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
v-if="showArticleActionMenu"
|
v-if="showArticleActionMenu"
|
||||||
:menu-items="articleMenuItems"
|
:menu-items="articleMenuItems"
|
||||||
class="mt-2 ltr:right-0 rtl:left-0 top-full"
|
class="mt-2 ltr:right-0 rtl:left-0 top-full"
|
||||||
@action="updateArticleStatus($event)"
|
@action="onMenuAction($event)"
|
||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
</div>
|
</div>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
<ArticlePendingChangesPopover
|
||||||
|
ref="pendingChangesPopoverRef"
|
||||||
|
:article-id="articleId"
|
||||||
|
@resolved="onDraftResolved"
|
||||||
|
@failed="onDraftFailed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { onKeyStroke } from '@vueuse/core';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
articleId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['resolved', 'failed']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const isOpen = ref(false);
|
||||||
|
const requestedStatus = ref(null);
|
||||||
|
// Which button is in flight, so only that one shows the spinner.
|
||||||
|
const activeAction = ref(null);
|
||||||
|
|
||||||
|
const articleUiFlags = useMapGetter('articles/uiFlags');
|
||||||
|
const isLoading = computed(
|
||||||
|
() => articleUiFlags.value(props.articleId).isUpdating
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the confirmation for a target status; resolving it also applies that status.
|
||||||
|
const open = status => {
|
||||||
|
requestedStatus.value = status;
|
||||||
|
activeAction.value = null;
|
||||||
|
isOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't let a click-outside or Escape dismiss the popover mid-action.
|
||||||
|
const dismiss = () => {
|
||||||
|
if (!isLoading.value) close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolve = async draftAction => {
|
||||||
|
activeAction.value = draftAction === 'publishDraft' ? 'apply' : 'discard';
|
||||||
|
try {
|
||||||
|
await store.dispatch(`articles/${draftAction}`, {
|
||||||
|
portalSlug: route.params.portalSlug,
|
||||||
|
articleId: props.articleId,
|
||||||
|
status: requestedStatus.value,
|
||||||
|
});
|
||||||
|
emit('resolved', requestedStatus.value);
|
||||||
|
close();
|
||||||
|
} catch (error) {
|
||||||
|
emit('failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onApply = () => resolve('publishDraft');
|
||||||
|
const onDiscard = () => resolve('discardDraft');
|
||||||
|
|
||||||
|
onKeyStroke('Escape', () => {
|
||||||
|
if (isOpen.value) dismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-show="isOpen"
|
||||||
|
v-on-click-outside="dismiss"
|
||||||
|
class="absolute z-50 flex flex-col gap-4 p-4 mt-2 outline outline-1 shadow-lg w-96 end-0 top-full rounded-xl bg-n-alpha-3 backdrop-blur-[100px] outline-n-container"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-base font-medium text-n-slate-12">
|
||||||
|
{{ t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.TITLE') }}
|
||||||
|
</h3>
|
||||||
|
<p class="mb-0 text-sm text-n-slate-11">
|
||||||
|
{{
|
||||||
|
t(
|
||||||
|
'HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DESCRIPTION'
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="shrink-0 -me-1 -mt-1"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="faded"
|
||||||
|
color="ruby"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1"
|
||||||
|
:is-loading="isLoading && activeAction === 'discard'"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:label="
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.DISCARD')
|
||||||
|
"
|
||||||
|
@click="onDiscard"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
color="blue"
|
||||||
|
size="sm"
|
||||||
|
class="flex-1"
|
||||||
|
:is-loading="isLoading && activeAction === 'apply'"
|
||||||
|
:disabled="isLoading"
|
||||||
|
:label="
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.PENDING_CHANGES_POPOVER.APPLY')
|
||||||
|
"
|
||||||
|
@click="onApply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+45
-9
@@ -5,8 +5,12 @@ import { useRouter, useRoute } from 'vue-router';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
import {
|
||||||
|
getArticleStatus,
|
||||||
|
ARTICLE_STATUSES,
|
||||||
|
} from 'dashboard/helper/portalHelper.js';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
|
||||||
|
|
||||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||||
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||||
@@ -145,6 +149,24 @@ const updateArticlesMeta = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshArticleMeta = async () => {
|
||||||
|
await updateArticlesMeta();
|
||||||
|
await updatePortalMeta();
|
||||||
|
};
|
||||||
|
|
||||||
|
// The card's pending-changes popover applies the status itself; surface the result.
|
||||||
|
const onDraftResolved = status => {
|
||||||
|
useAlert(getStatusMessage(status, true));
|
||||||
|
refreshArticleMeta();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDraftFailed = error => {
|
||||||
|
useAlert(
|
||||||
|
error?.message ||
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH_CHANGES_ERROR')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleArticleAction = async (action, { status, id }) => {
|
const handleArticleAction = async (action, { status, id }) => {
|
||||||
const { portalSlug } = route.params;
|
const { portalSlug } = route.params;
|
||||||
try {
|
try {
|
||||||
@@ -154,6 +176,14 @@ const handleArticleAction = async (action, { status, id }) => {
|
|||||||
articleId: id,
|
articleId: id,
|
||||||
});
|
});
|
||||||
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
|
useAlert(t('HELP_CENTER.DELETE_ARTICLE.API.SUCCESS_MESSAGE'));
|
||||||
|
} else if (action === 'discard-draft') {
|
||||||
|
await store.dispatch('articles/discardDraft', {
|
||||||
|
portalSlug,
|
||||||
|
articleId: id,
|
||||||
|
});
|
||||||
|
useAlert(
|
||||||
|
t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_SUCCESS')
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await store.dispatch('articles/update', {
|
await store.dispatch('articles/update', {
|
||||||
portalSlug,
|
portalSlug,
|
||||||
@@ -168,15 +198,16 @@ const handleArticleAction = async (action, { status, id }) => {
|
|||||||
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
useTrack(PORTALS_EVENTS.PUBLISH_ARTICLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await updateArticlesMeta();
|
await refreshArticleMeta();
|
||||||
await updatePortalMeta();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage =
|
const fallbackMessage =
|
||||||
error?.message ||
|
{
|
||||||
(action === 'delete'
|
delete: t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE'),
|
||||||
? t('HELP_CENTER.DELETE_ARTICLE.API.ERROR_MESSAGE')
|
'discard-draft': t(
|
||||||
: getStatusMessage(status, false));
|
'HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.DISCARD_CHANGES_ERROR'
|
||||||
useAlert(errorMessage);
|
),
|
||||||
|
}[action] ?? getStatusMessage(status, false);
|
||||||
|
useAlert(error?.message || fallbackMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,10 +241,15 @@ const updateArticle = ({ action, value, id }) => {
|
|||||||
:views="item.views || 0"
|
:views="item.views || 0"
|
||||||
:updated-at="item.updatedAt"
|
:updated-at="item.updatedAt"
|
||||||
:is-selected="selectedArticleIds.has(item.id)"
|
:is-selected="selectedArticleIds.has(item.id)"
|
||||||
|
:has-pending-changes="
|
||||||
|
item.status === ARTICLE_STATUSES.PUBLISHED && hasPendingChanges(item)
|
||||||
|
"
|
||||||
selectable
|
selectable
|
||||||
:show-selection-control="shouldShowSelectionControl(item.id)"
|
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||||
@open-article="openArticle"
|
@open-article="openArticle"
|
||||||
@article-action="updateArticle"
|
@article-action="updateArticle"
|
||||||
|
@draft-resolved="onDraftResolved"
|
||||||
|
@draft-failed="onDraftFailed"
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
+44
-7
@@ -3,10 +3,15 @@ import { ref, computed, watch } from 'vue';
|
|||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { OnClickOutside } from '@vueuse/components';
|
import { OnClickOutside } from '@vueuse/components';
|
||||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
import { useStore, useMapGetter } from 'dashboard/composables/store.js';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { debounce } from '@chatwoot/utils';
|
import { debounce } from '@chatwoot/utils';
|
||||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
import {
|
||||||
|
ARTICLE_TABS,
|
||||||
|
CATEGORY_ALL,
|
||||||
|
ARTICLE_STATUSES,
|
||||||
|
} from 'dashboard/helper/portalHelper';
|
||||||
|
import { hasPendingChanges } from 'dashboard/helper/articleDiffHelper';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||||
@@ -60,6 +65,7 @@ const emit = defineEmits([
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||||
@@ -227,15 +233,46 @@ const onBulkActionSuccess = message => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const bulkUpdateStatus = async status => {
|
const bulkUpdateStatus = async status => {
|
||||||
|
const selectedIds = [...selectedArticleIds.value];
|
||||||
|
const { portalSlug } = route.params;
|
||||||
|
|
||||||
|
const pendingIds = props.articles
|
||||||
|
.filter(
|
||||||
|
article =>
|
||||||
|
selectedIds.includes(article.id) &&
|
||||||
|
article.status === ARTICLE_STATUSES.PUBLISHED &&
|
||||||
|
hasPendingChanges(article)
|
||||||
|
)
|
||||||
|
.map(article => article.id);
|
||||||
|
|
||||||
|
// Publish promotes each pending draft; other status changes skip them.
|
||||||
|
const isPublishing = status === ARTICLE_STATUSES.PUBLISHED;
|
||||||
|
const draftIds = isPublishing ? pendingIds : [];
|
||||||
|
const skippedCount = isPublishing ? 0 : pendingIds.length;
|
||||||
|
const articleIds = selectedIds.filter(id => !pendingIds.includes(id));
|
||||||
|
|
||||||
|
if (!articleIds.length && !draftIds.length) {
|
||||||
|
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED_ALL'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await articlesAPI.bulkUpdateStatus({
|
if (articleIds.length) {
|
||||||
portalSlug: route.params.portalSlug,
|
await articlesAPI.bulkUpdateStatus({ portalSlug, articleIds, status });
|
||||||
articleIds: [...selectedArticleIds.value],
|
}
|
||||||
status,
|
await Promise.all(
|
||||||
});
|
draftIds.map(articleId =>
|
||||||
|
store.dispatch('articles/publishDraft', { portalSlug, articleId })
|
||||||
|
)
|
||||||
|
);
|
||||||
onBulkActionSuccess(
|
onBulkActionSuccess(
|
||||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||||
);
|
);
|
||||||
|
if (skippedCount) {
|
||||||
|
useAlert(
|
||||||
|
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SKIPPED', skippedCount)
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(
|
useAlert(
|
||||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
|
||||||
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
|
||||||
|
import PopularContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/PopularContentDialog.vue';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
@@ -26,6 +27,7 @@ const route = useRoute();
|
|||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
const contentDialogRef = ref(null);
|
const contentDialogRef = ref(null);
|
||||||
|
const popularContentDialogRef = ref(null);
|
||||||
|
|
||||||
const isLocaleDefault = code => {
|
const isLocaleDefault = code => {
|
||||||
return props.portal?.meta?.default_locale === code;
|
return props.portal?.meta?.default_locale === code;
|
||||||
@@ -154,6 +156,8 @@ const handleAction = ({ action }, localeCode) => {
|
|||||||
publishLocale({ localeCode: localeCode });
|
publishLocale({ localeCode: localeCode });
|
||||||
} else if (action === 'customize-content') {
|
} else if (action === 'customize-content') {
|
||||||
contentDialogRef.value.openForLocale(localeCode);
|
contentDialogRef.value.openForLocale(localeCode);
|
||||||
|
} else if (action === 'select-popular-content') {
|
||||||
|
popularContentDialogRef.value.openForLocale(localeCode);
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
deletePortalLocale({ localeCode: localeCode });
|
deletePortalLocale({ localeCode: localeCode });
|
||||||
}
|
}
|
||||||
@@ -174,5 +178,6 @@ const handleAction = ({ action }, localeCode) => {
|
|||||||
@action="handleAction($event, locale.code)"
|
@action="handleAction($event, locale.code)"
|
||||||
/>
|
/>
|
||||||
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
|
||||||
|
<PopularContentDialog ref="popularContentDialogRef" :portal="portal" />
|
||||||
</ul>
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||||
|
import categoriesAPI from 'dashboard/api/helpCenter/categories';
|
||||||
|
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import ReorderableMultiSelect from 'dashboard/components-next/combobox/ReorderableMultiSelect.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
portal: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const MAX_CATEGORIES = 3;
|
||||||
|
const MAX_ARTICLES = 6;
|
||||||
|
const KEY = 'HELP_CENTER.LOCALES_PAGE.POPULAR_CONTENT_DIALOG';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
const activeLocale = ref('');
|
||||||
|
// One flag per picker so each shows its skeleton until its own data loads.
|
||||||
|
const categoriesLoading = ref(false);
|
||||||
|
const { run: runArticleRequest, isPending: articlesLoading } =
|
||||||
|
useAbortableRequest();
|
||||||
|
|
||||||
|
const categoryOptions = ref([]);
|
||||||
|
const selectedCategoryIds = ref([]);
|
||||||
|
|
||||||
|
// Articles are searched server-side, so cache options for selected items that
|
||||||
|
// fall outside the current results.
|
||||||
|
const articleResults = ref([]);
|
||||||
|
const articleOptionById = ref({});
|
||||||
|
const selectedArticleIds = ref([]);
|
||||||
|
|
||||||
|
const popularContent = computed(
|
||||||
|
() => props.portal?.config?.popular_content || {}
|
||||||
|
);
|
||||||
|
|
||||||
|
const articleOptions = computed(() => {
|
||||||
|
const options = new Map();
|
||||||
|
selectedArticleIds.value.forEach(id => {
|
||||||
|
if (articleOptionById.value[id])
|
||||||
|
options.set(id, articleOptionById.value[id]);
|
||||||
|
});
|
||||||
|
articleResults.value.forEach(option => options.set(option.value, option));
|
||||||
|
return [...options.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
const toArticleOption = article => ({
|
||||||
|
value: article.id,
|
||||||
|
label: article.title,
|
||||||
|
subtitle: t(`${KEY}.ARTICLES.IN_CATEGORY`, {
|
||||||
|
category: article.category?.name || t(`${KEY}.ARTICLES.UNCATEGORIZED`),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCategories = async localeCode => {
|
||||||
|
const {
|
||||||
|
data: { payload },
|
||||||
|
} = await categoriesAPI.get({
|
||||||
|
portalSlug: props.portal?.slug,
|
||||||
|
locale: localeCode,
|
||||||
|
});
|
||||||
|
return payload.map(category => ({
|
||||||
|
value: category.id,
|
||||||
|
label: category.name,
|
||||||
|
subtitle: t(`${KEY}.CATEGORIES.ARTICLES_COUNT`, {
|
||||||
|
count: category.meta?.articles_count || 0,
|
||||||
|
}),
|
||||||
|
icon: category.icon,
|
||||||
|
iconColor: category.icon_color,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestArticles = async (query, signal) => {
|
||||||
|
const { data } = await articlesAPI.getArticles({
|
||||||
|
pageNumber: 1,
|
||||||
|
portalSlug: props.portal?.slug,
|
||||||
|
locale: activeLocale.value,
|
||||||
|
status: 'published',
|
||||||
|
query,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
articleResults.value = data.payload.map(article => {
|
||||||
|
const option = toArticleOption(article);
|
||||||
|
articleOptionById.value[article.id] = option;
|
||||||
|
return option;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchArticles = (query = '') =>
|
||||||
|
runArticleRequest(signal => requestArticles(query, signal));
|
||||||
|
|
||||||
|
const onArticleSearch = useDebounceFn(searchArticles, 300);
|
||||||
|
|
||||||
|
// Resolve options for pre-selected articles that aren't in the current results.
|
||||||
|
const cacheSelectedArticleOptions = async () => {
|
||||||
|
const unknownIds = selectedArticleIds.value.filter(
|
||||||
|
id => !articleOptionById.value[id]
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
unknownIds.map(async id => {
|
||||||
|
try {
|
||||||
|
const { data } = await articlesAPI.getArticle({
|
||||||
|
id,
|
||||||
|
portalSlug: props.portal?.slug,
|
||||||
|
});
|
||||||
|
articleOptionById.value[id] = toArticleOption(data.payload);
|
||||||
|
} catch {
|
||||||
|
// Deleted since it was picked; leave it for the id fallback.
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCategories = async localeCode => {
|
||||||
|
categoriesLoading.value = true;
|
||||||
|
try {
|
||||||
|
const options = await fetchCategories(localeCode);
|
||||||
|
// Reopened for another locale mid-flight; drop the stale response.
|
||||||
|
if (localeCode !== activeLocale.value) return;
|
||||||
|
categoryOptions.value = options;
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||||
|
} finally {
|
||||||
|
if (localeCode === activeLocale.value) categoriesLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
try {
|
||||||
|
await runArticleRequest(signal =>
|
||||||
|
Promise.all([cacheSelectedArticleOptions(), requestArticles('', signal)])
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openForLocale = localeCode => {
|
||||||
|
const existing = popularContent.value[localeCode] || {};
|
||||||
|
activeLocale.value = localeCode;
|
||||||
|
selectedCategoryIds.value = [...(existing.category_ids || [])];
|
||||||
|
selectedArticleIds.value = [...(existing.article_ids || [])];
|
||||||
|
categoryOptions.value = [];
|
||||||
|
articleResults.value = [];
|
||||||
|
articleOptionById.value = {};
|
||||||
|
dialogRef.value?.open();
|
||||||
|
|
||||||
|
loadCategories(localeCode);
|
||||||
|
loadArticles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
const updated = { ...popularContent.value };
|
||||||
|
const entry = {
|
||||||
|
category_ids: selectedCategoryIds.value,
|
||||||
|
article_ids: selectedArticleIds.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entry.category_ids.length || entry.article_ids.length) {
|
||||||
|
updated[activeLocale.value] = entry;
|
||||||
|
} else {
|
||||||
|
delete updated[activeLocale.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.dispatch('portals/update', {
|
||||||
|
portalSlug: props.portal?.slug,
|
||||||
|
config: { popular_content: updated },
|
||||||
|
});
|
||||||
|
dialogRef.value?.close();
|
||||||
|
useAlert(t(`${KEY}.API.SUCCESS_MESSAGE`));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error?.message || t(`${KEY}.API.ERROR_MESSAGE`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ openForLocale });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
type="edit"
|
||||||
|
:title="t(`${KEY}.TITLE`)"
|
||||||
|
:description="t(`${KEY}.DESCRIPTION`)"
|
||||||
|
:confirm-button-label="t(`${KEY}.CONFIRM`)"
|
||||||
|
@confirm="onConfirm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<ReorderableMultiSelect
|
||||||
|
v-model="selectedCategoryIds"
|
||||||
|
:options="categoryOptions"
|
||||||
|
:max="MAX_CATEGORIES"
|
||||||
|
:label="t(`${KEY}.CATEGORIES.LABEL`)"
|
||||||
|
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||||
|
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||||
|
:empty-state="t(`${KEY}.EMPTY`)"
|
||||||
|
fallback-icon="i-lucide-folder"
|
||||||
|
:loading="categoriesLoading"
|
||||||
|
>
|
||||||
|
<template #counter="{ remaining }">
|
||||||
|
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||||
|
</template>
|
||||||
|
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||||
|
</ReorderableMultiSelect>
|
||||||
|
<ReorderableMultiSelect
|
||||||
|
v-model="selectedArticleIds"
|
||||||
|
server-search
|
||||||
|
:options="articleOptions"
|
||||||
|
:max="MAX_ARTICLES"
|
||||||
|
:label="t(`${KEY}.ARTICLES.LABEL`)"
|
||||||
|
:add-label="t(`${KEY}.ADD_ANOTHER`)"
|
||||||
|
:search-placeholder="t(`${KEY}.SEARCH`)"
|
||||||
|
:empty-state="t(`${KEY}.EMPTY`)"
|
||||||
|
:loading="articlesLoading"
|
||||||
|
@search="onArticleSearch"
|
||||||
|
>
|
||||||
|
<template #counter="{ remaining }">
|
||||||
|
{{ t(`${KEY}.SLOTS_LEFT`, { count: remaining }) }}
|
||||||
|
</template>
|
||||||
|
<template #note>{{ t(`${KEY}.OVERRIDING_DEFAULTS`) }}</template>
|
||||||
|
</ReorderableMultiSelect>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
|||||||
const inbox = computed(() => props.stateInbox);
|
const inbox = computed(() => props.stateInbox);
|
||||||
|
|
||||||
const inboxIcon = computed(() => {
|
const inboxIcon = computed(() => {
|
||||||
const { channelType, medium } = inbox.value;
|
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||||
return getInboxIconByType(channelType, medium);
|
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSlaThreshold = computed(() => {
|
const hasSlaThreshold = computed(() => {
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
|
|||||||
ref="popoverRef"
|
ref="popoverRef"
|
||||||
:align="align"
|
:align="align"
|
||||||
:show-content-border="false"
|
:show-content-border="false"
|
||||||
|
:close-on-scroll="false"
|
||||||
@show="onPopoverShow"
|
@show="onPopoverShow"
|
||||||
@hide="onPopoverHide"
|
@hide="onPopoverHide"
|
||||||
>
|
>
|
||||||
|
|||||||
+3
-1
@@ -37,10 +37,11 @@ const transformInbox = ({
|
|||||||
channelType,
|
channelType,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
medium,
|
medium,
|
||||||
|
voiceEnabled,
|
||||||
...rest
|
...rest
|
||||||
}) => ({
|
}) => ({
|
||||||
id,
|
id,
|
||||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||||
label: generateLabelForContactableInboxesList({
|
label: generateLabelForContactableInboxesList({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
@@ -54,6 +55,7 @@ const transformInbox = ({
|
|||||||
phoneNumber,
|
phoneNumber,
|
||||||
channelType,
|
channelType,
|
||||||
medium,
|
medium,
|
||||||
|
voiceEnabled,
|
||||||
...rest,
|
...rest,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+14
@@ -79,6 +79,20 @@ describe('composeConversationHelper', () => {
|
|||||||
channelType: INBOX_TYPES.EMAIL,
|
channelType: INBOX_TYPES.EMAIL,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the voice glyph for a voice-enabled inbox', () => {
|
||||||
|
const inboxes = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'WhatsApp Cloud',
|
||||||
|
channelType: INBOX_TYPES.WHATSAPP,
|
||||||
|
voiceEnabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = helpers.buildContactableInboxesList(inboxes);
|
||||||
|
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getCapitalizedNameFromEmail', () => {
|
describe('getCapitalizedNameFromEmail', () => {
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, getCurrentInstance, ref, useTemplateRef } from 'vue';
|
||||||
|
import { downloadFile } from '@chatwoot/utils';
|
||||||
|
import { useEmitter } from 'dashboard/composables/emitter';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
src: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
fallbackDuration: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = [1, 1.5, 2];
|
||||||
|
|
||||||
|
const audioPlayer = useTemplateRef('audioPlayer');
|
||||||
|
const { uid } = getCurrentInstance();
|
||||||
|
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const currentTime = ref(0);
|
||||||
|
const duration = ref(props.fallbackDuration);
|
||||||
|
const playbackSpeed = ref(1);
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
const loadedDuration = audioPlayer.value?.duration;
|
||||||
|
if (Number.isFinite(loadedDuration)) duration.value = loadedDuration;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = time => {
|
||||||
|
if (!time || Number.isNaN(time)) return '00:00';
|
||||||
|
const minutes = Math.floor(time / 60);
|
||||||
|
const seconds = Math.floor(time % 60);
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playbackSpeedLabel = computed(() => `${playbackSpeed.value}x`);
|
||||||
|
|
||||||
|
const displayedTime = computed(() =>
|
||||||
|
formatTime(
|
||||||
|
isPlaying.value || currentTime.value ? currentTime.value : duration.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only one recording should play at a time across the list.
|
||||||
|
useEmitter('pause_playing_audio', currentPlayingId => {
|
||||||
|
if (currentPlayingId !== uid && isPlaying.value) {
|
||||||
|
audioPlayer.value?.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const playOrPause = () => {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
audioPlayer.value.pause();
|
||||||
|
isPlaying.value = false;
|
||||||
|
} else {
|
||||||
|
emitter.emit('pause_playing_audio', uid);
|
||||||
|
audioPlayer.value.play();
|
||||||
|
isPlaying.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
currentTime.value = audioPlayer.value?.currentTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seek = event => {
|
||||||
|
const time = Number(event.target.value);
|
||||||
|
audioPlayer.value.currentTime = time;
|
||||||
|
currentTime.value = time;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnd = () => {
|
||||||
|
isPlaying.value = false;
|
||||||
|
currentTime.value = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePlaybackSpeed = () => {
|
||||||
|
const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed.value);
|
||||||
|
playbackSpeed.value =
|
||||||
|
PLAYBACK_SPEEDS[(currentIndex + 1) % PLAYBACK_SPEEDS.length];
|
||||||
|
audioPlayer.value.playbackRate = playbackSpeed.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadRecording = () => {
|
||||||
|
downloadFile({ url: props.src, type: 'audio' });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center h-8 gap-2 px-2 rounded-full bg-n-alpha-1 dark:bg-n-alpha-2 overflow-hidden"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
ref="audioPlayer"
|
||||||
|
class="hidden"
|
||||||
|
playsinline
|
||||||
|
@loadedmetadata="onLoadedMetadata"
|
||||||
|
@timeupdate="onTimeUpdate"
|
||||||
|
@ended="onEnd"
|
||||||
|
>
|
||||||
|
<source :src="src" />
|
||||||
|
</audio>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="!w-6 !p-0 text-n-slate-12"
|
||||||
|
@click="playOrPause"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon
|
||||||
|
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
|
||||||
|
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
:max="duration || 0"
|
||||||
|
:value="currentTime"
|
||||||
|
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
|
||||||
|
@input="seek"
|
||||||
|
/>
|
||||||
|
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
|
||||||
|
{{ displayedTime }}
|
||||||
|
</span>
|
||||||
|
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
:label="playbackSpeedLabel"
|
||||||
|
class="!px-1 min-w-6 !text-n-slate-11"
|
||||||
|
@click="changePlaybackSpeed"
|
||||||
|
/>
|
||||||
|
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="slate"
|
||||||
|
size="xs"
|
||||||
|
class="!w-6 !p-0 text-n-slate-11"
|
||||||
|
@click="downloadRecording"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<Icon icon="i-lucide-download" class="size-4 flex-shrink-0" />
|
||||||
|
</template>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -61,10 +61,10 @@ const triggerAction = () => {
|
|||||||
<div>
|
<div>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
v-if="actionLabel"
|
v-if="actionLabel"
|
||||||
class="px-3 py-1 w-auto grid place-content-center rounded-lg"
|
class="px-3 py-1 w-auto grid place-content-center rounded-lg whitespace-nowrap"
|
||||||
:class="buttonClass"
|
:class="buttonClass"
|
||||||
@click="triggerAction"
|
@click="triggerAction"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const props = defineProps({
|
|||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
trailingIcon: { type: Boolean, default: false },
|
trailingIcon: { type: Boolean, default: false },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['click']);
|
const emit = defineEmits(['click']);
|
||||||
@@ -61,6 +62,7 @@ const handleClick = () => {
|
|||||||
:icon="icon"
|
:icon="icon"
|
||||||
:trailing-icon="trailingIcon"
|
:trailing-icon="trailingIcon"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
|
:disabled="disabled"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
@blur="resetConfirmMode"
|
@blur="resetConfirmMode"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<slot name="headerActions" />
|
||||||
<slot name="search" />
|
<slot name="search" />
|
||||||
<div
|
<div
|
||||||
v-if="!showPaywall && buttonLabel"
|
v-if="!showPaywall && buttonLabel"
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const onClickCancel = () => {
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
v-model="state.description"
|
v-model="state.description"
|
||||||
|
:max-length="500"
|
||||||
:label="
|
:label="
|
||||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: null,
|
default: null,
|
||||||
},
|
},
|
||||||
|
responsesCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
isSelected: {
|
isSelected: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value);
|
|||||||
const menuItems = computed(() => {
|
const menuItems = computed(() => {
|
||||||
const allOptions = [
|
const allOptions = [
|
||||||
{
|
{
|
||||||
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
|
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
|
||||||
value: 'viewRelatedQuestions',
|
value: 'viewDetails',
|
||||||
action: 'viewRelatedQuestions',
|
action: 'viewDetails',
|
||||||
icon: 'i-ph-tree-view-duotone',
|
icon: 'i-lucide-eye',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -143,6 +147,9 @@ const menuItems = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
|
||||||
|
const responsesCountLabel = computed(() =>
|
||||||
|
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
|
||||||
|
);
|
||||||
|
|
||||||
const displayLink = computed(() =>
|
const displayLink = computed(() =>
|
||||||
isPdf.value
|
isPdf.value
|
||||||
@@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => {
|
|||||||
emit('action', { action, value, id: props.id });
|
emit('action', { action, value, id: props.id });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewDetails = () => {
|
||||||
|
emit('action', { action: 'viewDetails', id: props.id });
|
||||||
|
};
|
||||||
|
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
emit('action', { action: 'sync', id: props.id });
|
emit('action', { action: 'sync', id: props.id });
|
||||||
};
|
};
|
||||||
@@ -177,9 +188,13 @@ const handleRetry = () => {
|
|||||||
<Checkbox v-model="modelValue" />
|
<Checkbox v-model="modelValue" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 justify-between w-full">
|
<div class="flex gap-1 justify-between w-full">
|
||||||
<span class="text-base text-n-slate-12 line-clamp-1">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
|
||||||
|
@click="handleViewDetails"
|
||||||
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</span>
|
</button>
|
||||||
<div v-if="showMenu" class="flex gap-2 items-center">
|
<div v-if="showMenu" class="flex gap-2 items-center">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
@@ -228,6 +243,9 @@ const handleRetry = () => {
|
|||||||
<Icon :icon="linkIcon" class="shrink-0" />
|
<Icon :icon="linkIcon" class="shrink-0" />
|
||||||
<span class="truncate">{{ displayLink }}</span>
|
<span class="truncate">{{ displayLink }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="text-sm shrink-0 text-n-slate-11">
|
||||||
|
{{ responsesCountLabel }}
|
||||||
|
</span>
|
||||||
<DocumentSyncStatus
|
<DocumentSyncStatus
|
||||||
v-if="showSyncStatus"
|
v-if="showSyncStatus"
|
||||||
:status="syncStatus"
|
:status="syncStatus"
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ const menuItems = computed(() => [
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const icon = computed(() => {
|
const icon = computed(() => {
|
||||||
const { medium, channel_type: type } = props.inbox;
|
const {
|
||||||
return getInboxIconByType(type, medium, 'outline');
|
medium,
|
||||||
|
channel_type: type,
|
||||||
|
voice_enabled: voiceEnabled,
|
||||||
|
} = props.inbox;
|
||||||
|
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAction = ({ action, value }) => {
|
const handleAction = ({ action, value }) => {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ const renderInstruction = instruction => () =>
|
|||||||
|
|
||||||
<TextArea
|
<TextArea
|
||||||
v-model="state.description"
|
v-model="state.description"
|
||||||
|
:max-length="500"
|
||||||
:label="
|
:label="
|
||||||
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
|
||||||
"
|
"
|
||||||
|
|||||||
+1
@@ -122,6 +122,7 @@ watch(
|
|||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.description"
|
v-model="state.description"
|
||||||
|
:max-length="500"
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
|
|||||||
+1
@@ -117,6 +117,7 @@ watch(
|
|||||||
|
|
||||||
<Editor
|
<Editor
|
||||||
v-model="state.description"
|
v-model="state.description"
|
||||||
|
:max-length="500"
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||||
:message="formErrors.description"
|
:message="formErrors.description"
|
||||||
|
|||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||||
|
import DocumentDetails from './DocumentDetails.vue';
|
||||||
|
|
||||||
|
const { dispatch, getterValues } = vi.hoisted(() => ({
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
getterValues: {
|
||||||
|
'captainResponses/getUIFlags': { value: { fetchingList: false } },
|
||||||
|
'captainResponses/getRecords': { value: [] },
|
||||||
|
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store', () => ({
|
||||||
|
useStore: () => ({ dispatch }),
|
||||||
|
useMapGetter: key => getterValues[key],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({ t: key => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const captainDocument = {
|
||||||
|
id: 42,
|
||||||
|
name: 'FAQ source',
|
||||||
|
external_link: 'https://example.com/docs',
|
||||||
|
assistant: { id: 7 },
|
||||||
|
content: 'Document content',
|
||||||
|
pdf_document: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DialogStub = {
|
||||||
|
name: 'Dialog',
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabBarStub = {
|
||||||
|
name: 'TabBar',
|
||||||
|
template:
|
||||||
|
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PaginationFooterStub = {
|
||||||
|
name: 'PaginationFooter',
|
||||||
|
template:
|
||||||
|
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DocumentDetails', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
dispatch.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
|
||||||
|
const wrapper = shallowMount(DocumentDetails, {
|
||||||
|
props: { captainDocument },
|
||||||
|
global: {
|
||||||
|
directives: { dompurifyHtml: {} },
|
||||||
|
stubs: {
|
||||||
|
Dialog: DialogStub,
|
||||||
|
TabBar: TabBarStub,
|
||||||
|
PaginationFooter: PaginationFooterStub,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
|
||||||
|
page: 1,
|
||||||
|
assistantId: 7,
|
||||||
|
documentId: 42,
|
||||||
|
});
|
||||||
|
|
||||||
|
await wrapper.get('[data-test="faq-tab"]').trigger('click');
|
||||||
|
await wrapper.get('[data-test="next-page"]').trigger('click');
|
||||||
|
|
||||||
|
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
|
||||||
|
page: 2,
|
||||||
|
assistantId: 7,
|
||||||
|
documentId: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user