Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62a990e1fe | ||
|
|
bbd7a46618 | ||
|
|
8d068dbac6 | ||
|
|
be73363b6e | ||
|
|
7a76cee723 | ||
|
|
4842f724ea | ||
|
|
bae20ca83e | ||
|
|
954e5844a8 | ||
|
|
0efab5fb43 | ||
|
|
34ad78b122 | ||
|
|
8c28fb74ec | ||
|
|
2ec33da5cd | ||
|
|
63d765b14b | ||
|
|
b11f2e4a0c | ||
|
|
d5a9747b81 | ||
|
|
a8ef787878 | ||
|
|
d85ab03444 | ||
|
|
b4d2fa5554 | ||
|
|
f1753f08b2 | ||
|
|
fc82c7939e | ||
|
|
45e065eca8 | ||
|
|
1ee939a910 | ||
|
|
ddb0535a93 | ||
|
|
42cbf7d3b9 | ||
|
|
3ffbaf680c | ||
|
|
887897ea98 | ||
|
|
8aee518149 | ||
|
|
5733b822e3 | ||
|
|
837343b12d | ||
|
|
1e52d23d7a | ||
|
|
fbb3479263 | ||
|
|
166a41c31c | ||
|
|
9f658fbfaa | ||
|
|
c183b2aa80 | ||
|
|
9a780e8716 | ||
|
|
e48d612a55 | ||
|
|
3662224396 | ||
|
|
e65e18e9c5 | ||
|
|
41082f845f | ||
|
|
d054c19770 | ||
|
|
961c4a8951 | ||
|
|
9d8af17b95 | ||
|
|
439ac80019 | ||
|
|
28fb05005d | ||
|
|
84c41f03d6 | ||
|
|
a17734676c | ||
|
|
70a63695c3 | ||
|
|
89b83c65c8 | ||
|
|
325c306a1e | ||
|
|
e0aa099487 | ||
|
|
398b84d5de | ||
|
|
ed30ff9c22 | ||
|
|
c25aac7a99 | ||
|
|
4c947f9260 | ||
|
|
cf417a2f3e | ||
|
|
11143672a6 | ||
|
|
7d2f01e402 | ||
|
|
8c013415b8 | ||
|
|
2144de92f2 | ||
|
|
0e376f4fe2 | ||
|
|
67cab7171d | ||
|
|
7a5385cc32 | ||
|
|
920a98ccf4 | ||
|
|
ae49af354d | ||
|
|
2485bc242d | ||
|
|
d1fa8d8c2f | ||
|
|
71fffdd2b9 | ||
|
|
eae9841eb4 | ||
|
|
412319462a | ||
|
|
160732c07d | ||
|
|
7a299307b8 | ||
|
|
08f49f5896 | ||
|
|
bf0a10c780 | ||
|
|
a752e56765 | ||
|
|
4d22d2c18e | ||
|
|
8600e9b170 | ||
|
|
64d2751c06 | ||
|
|
f6d97c56b4 | ||
|
|
fc63c7bddf | ||
|
|
916b510ecf | ||
|
|
4a73de1581 | ||
|
|
d09ece0b85 | ||
|
|
68f60b0672 | ||
|
|
e1445e4c2d | ||
|
|
2579e2b93b | ||
|
|
f0edabebb1 | ||
|
|
3a36f096d2 | ||
|
|
5264a18dc8 | ||
|
|
30e50c00e8 | ||
|
|
2e84ebb4de | ||
|
|
cf1fdff04b | ||
|
|
f04eb85175 | ||
|
|
637eddad34 | ||
|
|
d3e1ad56ac | ||
|
|
b95e75f4ca | ||
|
|
44ad817fb3 | ||
|
|
f98cfd2184 | ||
|
|
7723f19816 | ||
|
|
69216db45d | ||
|
|
c272de5be9 | ||
|
|
f6f688acbb | ||
|
|
61f5198b99 | ||
|
|
1dd91cda92 | ||
|
|
c3635128ef | ||
|
|
cb5b4a539c | ||
|
|
dfb93ef58f | ||
|
|
d1ab6edaf1 | ||
|
|
07dc14b3fa |
+1
-1
@@ -1 +1 @@
|
||||
4.16.0
|
||||
4.16.1
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
# It initializes with necessary attributes and provides a perform method
|
||||
# to create a user and account user in a transaction.
|
||||
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.
|
||||
# @param email [String] the email 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.
|
||||
# @return [User] the created user.
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
account.with_lock do
|
||||
raise LimitExceededError unless can_add_agent?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@user = find_or_create_user
|
||||
create_account_user
|
||||
end
|
||||
end
|
||||
@user
|
||||
end
|
||||
|
||||
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.
|
||||
# @return [User] the found or created user.
|
||||
def find_or_create_user
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
before_action :agent_bot, except: [:index, :create]
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_agent, except: [:create, :index, :bulk_create]
|
||||
before_action :check_authorization
|
||||
before_action :validate_limit, only: [:create]
|
||||
before_action :validate_limit_for_bulk_create, only: [:bulk_create]
|
||||
|
||||
def index
|
||||
@agents = agents
|
||||
@@ -20,6 +18,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
)
|
||||
|
||||
@agent = builder.perform
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -36,25 +36,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
def bulk_create
|
||||
emails = params[:emails]
|
||||
|
||||
emails.each do |email|
|
||||
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
|
||||
|
||||
bulk_create_agents(emails)
|
||||
# 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
|
||||
Current.account.custom_attributes.delete('onboarding_step')
|
||||
Current.account.save!
|
||||
clear_onboarding_step
|
||||
head :ok
|
||||
rescue AgentBuilder::LimitExceededError => e
|
||||
render_payment_required(e.message)
|
||||
end
|
||||
|
||||
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] })
|
||||
end
|
||||
|
||||
def validate_limit_for_bulk_create
|
||||
limit_available = params[:emails].count <= available_agent_count
|
||||
def bulk_create_agents(emails)
|
||||
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
|
||||
|
||||
def validate_limit
|
||||
render_payment_required('Account limit exceeded. Please purchase more licenses') unless can_add_agent?
|
||||
def create_agent_from_email(email)
|
||||
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
|
||||
|
||||
def available_agent_count
|
||||
Current.account.usage_limits[:agents] - agents.count
|
||||
end
|
||||
|
||||
def can_add_agent?
|
||||
available_agent_count.positive?
|
||||
Current.account.usage_limits[:agents] - Current.account.account_users.count
|
||||
end
|
||||
|
||||
def delete_user_record(agent)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :authorize_account_update, only: [:update]
|
||||
|
||||
def show
|
||||
|
||||
@@ -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 :check_authorization
|
||||
|
||||
@@ -35,10 +35,6 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
@hook = Current.account.hooks.find(params[:id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(:hook)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||
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_hook, only: [:destroy]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
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 :check_authorization, only: [:destroy]
|
||||
|
||||
def 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
|
||||
before_action :setup_shopify_context, only: [:orders]
|
||||
before_action :fetch_hook, except: [:auth]
|
||||
before_action :check_authorization, only: [:destroy]
|
||||
before_action :validate_contact, only: [:orders]
|
||||
|
||||
def auth
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :fetch_label, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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? }
|
||||
@@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
|
||||
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
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
account: Current.account,
|
||||
@@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts:
|
||||
def can_reconfigure_channel?
|
||||
channel = @inbox.channel
|
||||
return false unless channel.provider == 'whatsapp_cloud'
|
||||
|
||||
# Reconfiguring a live embedded-signup channel requires the feature flag.
|
||||
return true if ChatwootApp.chatwoot_cloud?
|
||||
return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup'
|
||||
|
||||
true
|
||||
|
||||
@@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||
phone_number_id = metadata[:phone_number_id]
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
|
||||
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}"
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
|
||||
@@ -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();
|
||||
@@ -26,15 +26,25 @@ class CaptainAssistant extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getStats({ assistantId, range }) {
|
||||
return axios.get(`${this.url}/${assistantId}/stats`, {
|
||||
getMetrics({ assistantId, range, signal }) {
|
||||
const requestConfig = {
|
||||
params: { range, timezone_offset: getTimezoneOffset() },
|
||||
});
|
||||
};
|
||||
if (signal) requestConfig.signal = signal;
|
||||
|
||||
return axios.get(`${this.url}/${assistantId}/metrics`, requestConfig);
|
||||
}
|
||||
|
||||
getSummary({ assistantId, range }) {
|
||||
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() },
|
||||
params: { range, timezone_offset: getTimezoneOffset(), stats },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainFaqSuggestions extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/faq_suggestions', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, status = 'open', signal } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
update(id, data) {
|
||||
return axios.patch(`${this.url}/${id}`, { faq_suggestion: data });
|
||||
}
|
||||
|
||||
approve(id, data) {
|
||||
return axios.post(`${this.url}/${id}/approve`, {
|
||||
faq_suggestion: data,
|
||||
});
|
||||
}
|
||||
|
||||
dismiss(id) {
|
||||
return axios.post(`${this.url}/${id}/dismiss`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainFaqSuggestions();
|
||||
@@ -6,15 +6,15 @@ class CaptainResponses extends ApiClient {
|
||||
super('captain/assistant_responses', { accountScoped: true });
|
||||
}
|
||||
|
||||
get({ page = 1, search, assistantId, documentId, status } = {}) {
|
||||
get({ page = 1, search, assistantId, documentId, signal } = {}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
page,
|
||||
search,
|
||||
assistant_id: assistantId,
|
||||
document_id: documentId,
|
||||
status,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient {
|
||||
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
|
||||
.post(`${this.url}/initiate`, {
|
||||
conversation_id: conversationId,
|
||||
contact_id: contactId,
|
||||
inbox_id: inboxId,
|
||||
sdp_offer: sdpOffer,
|
||||
})
|
||||
.then(r => r.data);
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
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(() =>
|
||||
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||
);
|
||||
|
||||
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-40 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" />
|
||||
<template v-if="agentActionLabel">
|
||||
<span
|
||||
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||
>
|
||||
{{ 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>
|
||||
</template>
|
||||
<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.5 justify-start min-w-14 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">
|
||||
{{ 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 w-16 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,250 @@
|
||||
<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';
|
||||
|
||||
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 selectedAssigneeLabel = computed(
|
||||
() =>
|
||||
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
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-3">
|
||||
<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"
|
||||
@click="setActivity(null)"
|
||||
>
|
||||
{{ activeChipLabel }}
|
||||
<Icon icon="i-lucide-x" />
|
||||
</Button>
|
||||
<div class="w-px h-4 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-12"
|
||||
@click="setActivity(chip)"
|
||||
/>
|
||||
<OnClickOutside
|
||||
class="relative shrink-0"
|
||||
@trigger="closeOnOutside('activity')"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
class="text-n-slate-12"
|
||||
@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-lucide-user-round-cog"
|
||||
class="max-w-52 text-n-slate-12"
|
||||
@click="toggleMenu('assignee')"
|
||||
>
|
||||
<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"
|
||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||
@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 },
|
||||
};
|
||||
@@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -83,39 +82,18 @@ const navigateToConversation = conversationId => {
|
||||
|
||||
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) => {
|
||||
// WhatsApp /initiate is conversation-scoped, so we must hand it a
|
||||
// conversation. Use the caller's hint when given (in-conversation flow);
|
||||
// otherwise pick the most recent one in the inbox.
|
||||
const conversationId =
|
||||
conversationIdHint || (await findWhatsappConversationId(inboxId));
|
||||
if (!conversationId) {
|
||||
useAlert(t('CONTACT_PANEL.CALL_FAILED'));
|
||||
return;
|
||||
}
|
||||
|
||||
const response =
|
||||
await whatsappCallSession.initiateOutboundCall(conversationId);
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationIdHint
|
||||
? { conversationId: conversationIdHint }
|
||||
: { contactId: props.contactId, inboxId }
|
||||
);
|
||||
// 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
|
||||
// claiming success.
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
|
||||
const conversationId = response?.conversation_id || conversationIdHint;
|
||||
if (!response?.id) {
|
||||
// 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
|
||||
|
||||
@@ -234,6 +234,7 @@ onMounted(() => resetContacts());
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
:close-on-scroll="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
|
||||
@@ -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-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,115 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['approve', 'dismiss', 'review']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const sourceLabel = computed(() =>
|
||||
t('CAPTAIN.FAQ_SUGGESTIONS.SOURCE_COUNT', {
|
||||
count: props.suggestion.source_count,
|
||||
})
|
||||
);
|
||||
|
||||
const updatedAt = computed(() =>
|
||||
dynamicTime(props.suggestion.updated_at || props.suggestion.created_at)
|
||||
);
|
||||
|
||||
const language = computed(() =>
|
||||
props.suggestion.language?.replace('_', '-').toUpperCase()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-brand/10 px-2.5 py-1 text-xs font-medium text-n-blue-11"
|
||||
>
|
||||
<Icon icon="i-lucide-messages-square" class="size-3.5" />
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="language"
|
||||
class="rounded-full bg-n-alpha-2 px-2.5 py-1 text-xs font-medium text-n-slate-11"
|
||||
>
|
||||
{{ language }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ suggestion.question }}
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.REVIEW')"
|
||||
icon="i-lucide-list-collapse"
|
||||
size="sm"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
class="shrink-0"
|
||||
@click="emit('review', suggestion)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="line-clamp-2 text-sm leading-5 text-n-slate-11">
|
||||
{{ suggestion.answer }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-3 border-t border-n-weak pt-2.5 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 text-xs text-n-slate-10"
|
||||
>
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5">
|
||||
<Icon icon="i-woot-captain" class="size-3.5 shrink-0" />
|
||||
<span class="truncate">{{ suggestion.assistant?.name }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Icon icon="i-lucide-clock-3" class="size-3.5" />
|
||||
{{ updatedAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
|
||||
icon="i-lucide-circle-x"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
:disabled="isLoading"
|
||||
@click="emit('dismiss', suggestion)"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
size="sm"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
@click="emit('approve', suggestion)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
+5
-39
@@ -7,24 +7,7 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'approved',
|
||||
validator: value => ['approved', 'pending'].includes(value),
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'clearFilters']);
|
||||
|
||||
const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
@@ -32,15 +15,10 @@ const { replaceInstallationName } = useBranding();
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const onClearFilters = () => {
|
||||
emit('clearFilters');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
v-if="isApproved"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
@@ -50,16 +28,12 @@ const onClearFilters = () => {
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="
|
||||
isPending
|
||||
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
show-backdrop
|
||||
>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
<template #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ResponseCard
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
@@ -77,18 +51,10 @@ const onClearFilters = () => {
|
||||
<template #actions>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
v-if="isApproved"
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="isPending && hasActiveFilters"
|
||||
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="onClearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
|
||||
+14
-9
@@ -6,7 +6,12 @@ import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
default: () => ({
|
||||
approved: 0,
|
||||
suggestions: 0,
|
||||
documents: 0,
|
||||
coverage: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,16 +36,16 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// Thin coverage paired with a large review backlog: approving the pending FAQs
|
||||
// Thin coverage paired with a large review backlog: approving FAQ suggestions
|
||||
// is the quickest lever to lift auto-resolution, so nudge the team to act.
|
||||
const COVERAGE_THRESHOLD = 85;
|
||||
const PENDING_THRESHOLD = 100;
|
||||
const SUGGESTION_THRESHOLD = 100;
|
||||
|
||||
const showBanner = computed(
|
||||
() =>
|
||||
!dismissed.value &&
|
||||
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
|
||||
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
|
||||
(props.knowledge?.suggestions ?? 0) > SUGGESTION_THRESHOLD
|
||||
);
|
||||
|
||||
const dismiss = () => {
|
||||
@@ -48,9 +53,9 @@ const dismiss = () => {
|
||||
dismissed.value = true;
|
||||
};
|
||||
|
||||
const goToPending = () => {
|
||||
const goToSuggestions = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_responses_pending',
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: route.params.assistantId,
|
||||
@@ -61,15 +66,15 @@ const goToPending = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
|
||||
:class="{ hidden: !showBanner }"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 i-lucide-triangle-alert size-4" />
|
||||
<span class="truncate">
|
||||
{{
|
||||
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
|
||||
count: knowledge.pending,
|
||||
count: knowledge.suggestions,
|
||||
coverage: knowledge.coverage,
|
||||
})
|
||||
}}
|
||||
@@ -79,7 +84,7 @@ const goToPending = () => {
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
|
||||
@click="goToPending"
|
||||
@click="goToSuggestions"
|
||||
>
|
||||
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
|
||||
</button>
|
||||
|
||||
+10
-5
@@ -6,7 +6,12 @@ import { useI18n } from 'vue-i18n';
|
||||
const props = defineProps({
|
||||
knowledge: {
|
||||
type: Object,
|
||||
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
|
||||
default: () => ({
|
||||
approved: 0,
|
||||
suggestions: 0,
|
||||
documents: 0,
|
||||
coverage: 0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -31,10 +36,10 @@ const stats = computed(() => [
|
||||
to: linkTo('captain_assistants_responses_index'),
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
value: props.knowledge.pending,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
|
||||
to: linkTo('captain_assistants_responses_pending'),
|
||||
key: 'suggestions',
|
||||
value: props.knowledge.suggestions,
|
||||
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.SUGGESTIONS'),
|
||||
to: linkTo('captain_assistants_faq_suggestions'),
|
||||
},
|
||||
{
|
||||
key: 'documents',
|
||||
|
||||
+6
-1
@@ -9,6 +9,7 @@ const props = defineProps({
|
||||
// null = neutral, true = good direction, false = bad direction
|
||||
trendGood: { type: Boolean, default: null },
|
||||
clickable: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
@@ -45,7 +46,11 @@ const onActivate = () => {
|
||||
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end justify-between gap-2">
|
||||
<div v-if="loading" class="flex items-end justify-between gap-2">
|
||||
<div class="w-20 rounded h-9 bg-n-slate-3 animate-pulse" />
|
||||
<div class="w-10 h-5 rounded bg-n-slate-3 animate-pulse" />
|
||||
</div>
|
||||
<div v-else class="flex items-end justify-between gap-2">
|
||||
<span
|
||||
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
|
||||
>
|
||||
|
||||
+28
-5
@@ -9,6 +9,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: '30',
|
||||
},
|
||||
stats: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
|
||||
const welcomeMarkdown = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Increments on every fetch so a slow response for a superseded
|
||||
// range/stats/assistant can't overwrite the latest request's state.
|
||||
let fetchToken = 0;
|
||||
|
||||
const fetchSummary = async () => {
|
||||
fetchToken += 1;
|
||||
const token = fetchToken;
|
||||
|
||||
if (!props.stats) {
|
||||
welcomeMarkdown.value = '';
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
let message = '';
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getSummary({
|
||||
assistantId: assistantId.value,
|
||||
range: props.range,
|
||||
stats: props.stats,
|
||||
});
|
||||
welcomeMarkdown.value = data.message ?? '';
|
||||
message = data.message ?? '';
|
||||
} catch {
|
||||
welcomeMarkdown.value = '';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
message = '';
|
||||
}
|
||||
|
||||
if (token !== fetchToken) return;
|
||||
welcomeMarkdown.value = message;
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
|
||||
watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
// Render through the shared markdown formatter (html disabled, so it is safe)
|
||||
// used everywhere else for Captain output, instead of a bespoke parser. It
|
||||
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import FaqSuggestionReviewDialog from './FaqSuggestionReviewDialog.vue';
|
||||
|
||||
const { dispatch, push, uiFlags } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
push: vi.fn(),
|
||||
uiFlags: {
|
||||
value: {
|
||||
fetchingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({ dispatch }),
|
||||
useMapGetter: () => uiFlags,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
}));
|
||||
|
||||
const DialogStub = {
|
||||
methods: { close: vi.fn() },
|
||||
template: `
|
||||
<div>
|
||||
<slot name="description" />
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
describe('FaqSuggestionReviewDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('keeps a visible error when source conversations fail to load', async () => {
|
||||
dispatch.mockRejectedValueOnce(new Error('Request failed'));
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 2,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS'
|
||||
);
|
||||
expect(wrapper.text()).not.toContain(
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens source conversations using their display ID', async () => {
|
||||
dispatch.mockResolvedValueOnce({
|
||||
observations: [
|
||||
{
|
||||
id: 1,
|
||||
generated_question: 'How do I enable the feature?',
|
||||
created_at: 1,
|
||||
conversation: { id: 99, display_id: 42 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('section button').trigger('click');
|
||||
|
||||
expect(push).toHaveBeenCalledWith({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the review actions to users who can open the page', async () => {
|
||||
dispatch.mockResolvedValueOnce({ observations: [] });
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestionReviewDialog, {
|
||||
props: {
|
||||
suggestion: {
|
||||
id: 1,
|
||||
question: 'How do I enable the feature?',
|
||||
answer: 'Turn it on in settings.',
|
||||
source_count: 1,
|
||||
assistant: { name: 'Support assistant' },
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: { Dialog: DialogStub },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(
|
||||
wrapper.findAllComponents(Button).map(button => button.props('label'))
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.DISMISS',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.SAVE',
|
||||
'CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ',
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
|
||||
const props = defineProps({
|
||||
suggestion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'resolved']);
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const details = ref(null);
|
||||
const detailsError = ref(false);
|
||||
|
||||
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const isSaving = computed(() => uiFlags.value.updatingItem);
|
||||
const isDismissing = computed(() => uiFlags.value.deletingItem);
|
||||
|
||||
const state = reactive({
|
||||
question: props.suggestion.question,
|
||||
answer: props.suggestion.answer,
|
||||
});
|
||||
|
||||
const observations = computed(() => details.value?.observations || []);
|
||||
const isInvalid = computed(
|
||||
() => !state.question.trim() || !state.answer.trim()
|
||||
);
|
||||
|
||||
const loadDetails = async () => {
|
||||
detailsError.value = false;
|
||||
|
||||
try {
|
||||
details.value = await store.dispatch(
|
||||
'captainFaqSuggestions/show',
|
||||
props.suggestion.id
|
||||
);
|
||||
} catch (error) {
|
||||
detailsError.value = true;
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => dialogRef.value.close();
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updatedSuggestion = await store.dispatch(
|
||||
'captainFaqSuggestions/update',
|
||||
{
|
||||
id: props.suggestion.id,
|
||||
question: state.question.trim(),
|
||||
answer: state.answer.trim(),
|
||||
}
|
||||
);
|
||||
details.value = { ...details.value, ...updatedSuggestion };
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.SAVED'));
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.SAVE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/approve', {
|
||||
id: props.suggestion.id,
|
||||
question: state.question.trim(),
|
||||
answer: state.answer.trim(),
|
||||
});
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
|
||||
emit('resolved', { id: props.suggestion.id, action: 'approved' });
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/dismiss', props.suggestion.id);
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
|
||||
emit('resolved', { id: props.suggestion.id, action: 'dismissed' });
|
||||
close();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
|
||||
}
|
||||
};
|
||||
|
||||
const openConversation = conversationId => {
|
||||
close();
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadDetails);
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
position="top"
|
||||
overflow-y-auto
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.DESCRIPTION', {
|
||||
count: suggestion.source_count,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="-me-1.5 -mt-1.5 shrink-0"
|
||||
:aria-label="$t('DIALOG.BUTTONS.CANCEL')"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid min-h-0 grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_18rem]"
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<Input
|
||||
v-model="state.question"
|
||||
:label="$t('CAPTAIN.RESPONSES.FORM.QUESTION.LABEL')"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
|
||||
/>
|
||||
<TextArea
|
||||
v-model="state.answer"
|
||||
:label="$t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.FORM.ANSWER.PLACEHOLDER')"
|
||||
auto-height
|
||||
resize
|
||||
min-height="10rem"
|
||||
max-height="20rem"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-n-slate-10">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
|
||||
>
|
||||
<Icon icon="i-lucide-languages" class="size-3.5" />
|
||||
{{ suggestion.language?.replace('_', '-').toUpperCase() }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-n-alpha-2 px-2.5 py-1"
|
||||
>
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ suggestion.assistant?.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="min-h-0 rounded-xl border border-n-weak bg-n-alpha-2 p-3">
|
||||
<div class="mb-3 flex items-center justify-between gap-2 px-1">
|
||||
<h4 class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCES') }}
|
||||
</h4>
|
||||
<span class="text-xs tabular-nums text-n-slate-10">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.SOURCE_PROGRESS', {
|
||||
visible: observations.length,
|
||||
total: suggestion.source_count,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex max-h-96 flex-col gap-2 overflow-hidden"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div
|
||||
v-for="n in Math.min(suggestion.source_count, 6)"
|
||||
:key="n"
|
||||
class="flex w-full animate-pulse flex-col gap-1.5 rounded-lg outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2 p-3"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium">
|
||||
<span
|
||||
class="inline-block h-2 w-24 rounded-sm bg-n-alpha-2 align-middle"
|
||||
/>
|
||||
</span>
|
||||
<span class="size-3.5 shrink-0 rounded-sm bg-n-alpha-2" />
|
||||
</span>
|
||||
<span class="flex min-h-[2.5rem] flex-col text-xs leading-5">
|
||||
<span class="h-2 w-full rounded-sm bg-n-alpha-2" />
|
||||
<span class="mt-2 h-2 w-4/5 rounded-sm bg-n-alpha-2" />
|
||||
</span>
|
||||
<span class="text-xs">
|
||||
<span
|
||||
class="inline-block h-2 w-16 rounded-sm bg-n-alpha-2 align-middle"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="detailsError"
|
||||
role="alert"
|
||||
class="flex min-h-[10rem] flex-col items-center justify-center gap-3 px-4 text-center"
|
||||
>
|
||||
<Icon icon="i-lucide-circle-alert" class="size-5 text-n-ruby-10" />
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD_DETAILS') }}
|
||||
</p>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.RETRY')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="loadDetails"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!observations.length"
|
||||
class="flex min-h-[10rem] items-center justify-center px-4 text-center text-sm text-n-slate-10"
|
||||
>
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.DETAILS.NO_SOURCES') }}
|
||||
</div>
|
||||
<div v-else class="flex max-h-96 flex-col gap-2 overflow-y-auto">
|
||||
<button
|
||||
v-for="observation in observations"
|
||||
:key="observation.id"
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1.5 rounded-lg border border-n-weak bg-n-solid-2 p-3 text-start transition-colors hover:border-n-brand"
|
||||
@click="openConversation(observation.conversation.display_id)"
|
||||
>
|
||||
<span class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-medium text-n-blue-11">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION', {
|
||||
id: observation.conversation.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<Icon
|
||||
icon="i-lucide-arrow-up-right"
|
||||
class="size-3.5 text-n-slate-10"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="line-clamp-2 min-h-[2.5rem] text-xs leading-5 text-n-slate-11"
|
||||
>
|
||||
{{ observation.generated_question }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ dynamicTime(observation.created_at) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div
|
||||
class="flex w-full flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.DISMISS')"
|
||||
icon="i-lucide-circle-x"
|
||||
variant="ghost"
|
||||
color="ruby"
|
||||
:is-loading="isDismissing"
|
||||
:disabled="isSaving || isDismissing"
|
||||
@click="handleDismiss"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.SAVE')"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isInvalid || isSaving || isDismissing"
|
||||
@click="handleSave"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.APPROVE_FAQ')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
:is-loading="isSaving"
|
||||
:disabled="isInvalid || isSaving || isDismissing"
|
||||
@click="handleApprove"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
-10
@@ -24,16 +24,6 @@ const isAssistantActive = assistant => {
|
||||
|
||||
const fetchDataForRoute = async (routeName, assistantId) => {
|
||||
const dataFetchMap = {
|
||||
captain_assistants_responses_index: async () => {
|
||||
await store.dispatch('captainResponses/get', { assistantId });
|
||||
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
|
||||
},
|
||||
captain_assistants_responses_pending: async () => {
|
||||
await store.dispatch('captainResponses/get', {
|
||||
assistantId,
|
||||
status: 'pending',
|
||||
});
|
||||
},
|
||||
captain_assistants_documents_index: async () => {
|
||||
await store.dispatch('captainDocuments/get', { assistantId });
|
||||
},
|
||||
|
||||
+26
-52
@@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
import {
|
||||
isTwilioComplete,
|
||||
isTwilioMediaTemplate,
|
||||
getTwilioMediaVariableKey,
|
||||
getTwilioMediaUrl,
|
||||
applyTwilioMediaFilename,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
@@ -40,30 +45,23 @@ const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
// Media-template detection and variable extraction are shared with the mobile
|
||||
// app via @chatwoot/utils.
|
||||
const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template));
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
const mediaVariableKey = computed(() =>
|
||||
getTwilioMediaVariableKey(props.template)
|
||||
);
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
const hasMediaVariable = computed(() => mediaVariableKey.value !== null);
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
const templateMediaUrl = computed(() =>
|
||||
hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : ''
|
||||
);
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
@@ -83,26 +81,10 @@ const renderedTemplate = computed(() => {
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// Completeness validation is shared with the mobile app via @chatwoot/utils.
|
||||
const isFormInvalid = computed(
|
||||
() => !isTwilioComplete(props.template, processedParams.value)
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
@@ -135,19 +117,11 @@ const sendMessage = () => {
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
// For media templates, reduce the media URL to a filename before sending.
|
||||
const processedParameters = applyTwilioMediaFilename(
|
||||
props.template,
|
||||
processedParams.value
|
||||
);
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n, I18nT } from 'vue-i18n';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useMessageContext } from './provider.js';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from './constants';
|
||||
|
||||
const props = defineProps({
|
||||
messageId: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { orientation, variant, createdAt } = useMessageContext();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const isOpen = ref(false);
|
||||
|
||||
const showSparkle = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
|
||||
const session = computed(() =>
|
||||
store.getters['captainAgentSessions/getSessionByMessageId'](props.messageId)
|
||||
);
|
||||
const hasFetched = computed(() =>
|
||||
store.getters['captainAgentSessions/hasFetched'](props.messageId)
|
||||
);
|
||||
const isLoading = computed(
|
||||
() =>
|
||||
!hasFetched.value ||
|
||||
store.getters['captainAgentSessions/isFetching'](props.messageId)
|
||||
);
|
||||
|
||||
const citations = computed(() => session.value?.citations || []);
|
||||
|
||||
const scenarioTitles = computed(() =>
|
||||
(session.value?.scenarios || []).reduce((map, scenario) => {
|
||||
map[scenario.id] = scenario.title;
|
||||
return map;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// Fallback for agents without a matching scenario title:
|
||||
// "chatwoot_assistant" → "Chatwoot assistant",
|
||||
// "scenario_5_chatwoot_uptime_agent" → "Chatwoot uptime".
|
||||
const humanizeAgentName = agentName => {
|
||||
const label = agentName
|
||||
.replace(/^scenario_\d+_/, '')
|
||||
.replace(/_agent$/, '')
|
||||
.replaceAll('_', ' ')
|
||||
.trim();
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
};
|
||||
|
||||
const handoffLabel = agentName => {
|
||||
const scenarioId = agentName.match(/^scenario_(\d+)/)?.[1];
|
||||
return scenarioTitles.value[scenarioId] || humanizeAgentName(agentName);
|
||||
};
|
||||
|
||||
const ACRONYMS = ['faq', 'api', 'url', 'id', 'sla', 'csat'];
|
||||
|
||||
// Tool names arrive as RubyLLM identifiers like
|
||||
// "captain--tools--faq_lookup" or "custom_get_status_page_overview";
|
||||
// show "FAQ Lookup" / "Get Status Page Overview" instead.
|
||||
const humanizeToolName = name => {
|
||||
return (name || '')
|
||||
.split('--')
|
||||
.pop()
|
||||
.replace(/^custom_/, '')
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(word =>
|
||||
ACRONYMS.includes(word)
|
||||
? word.toUpperCase()
|
||||
: word.charAt(0).toUpperCase() + word.slice(1)
|
||||
)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
// Argument keys are camelCased by the store ("labelName"); show "Label Name".
|
||||
const humanizeArgumentKey = key =>
|
||||
key
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
|
||||
const formatArguments = args => {
|
||||
if (!args || typeof args !== 'object') return '';
|
||||
return Object.entries(args)
|
||||
.map(([key, value]) => `${humanizeArgumentKey(key)}: ${value}`)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// Timeline of what Captain did during the run: tool calls (with their
|
||||
// arguments) and scenario/agent handoffs. Message bodies and raw tool
|
||||
// results are intentionally not echoed here.
|
||||
const steps = computed(() => {
|
||||
const runContext = session.value?.runContext;
|
||||
const result = [];
|
||||
let currentAgent = null;
|
||||
|
||||
(Array.isArray(runContext) ? runContext : []).forEach(entry => {
|
||||
if (entry?.role !== 'assistant') return;
|
||||
|
||||
const agentName = entry.agentName;
|
||||
if (agentName && agentName !== currentAgent) {
|
||||
if (currentAgent !== null) {
|
||||
result.push({ type: 'handoff', name: handoffLabel(agentName) });
|
||||
}
|
||||
currentAgent = agentName;
|
||||
}
|
||||
|
||||
(entry.toolCalls || []).forEach(call => {
|
||||
// Agent-to-agent transfers surface as "handoff_to_<agent>" tool calls;
|
||||
// the agent_name change above already yields a handoff step for them.
|
||||
if (call.name?.startsWith('handoff_to_')) return;
|
||||
|
||||
result.push({
|
||||
type: 'tool',
|
||||
name: humanizeToolName(call.name),
|
||||
detail: formatArguments(call.arguments),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// The final assistant entry stores structured content ({response, reasoning});
|
||||
// surface the model's reasoning for the reply it produced.
|
||||
const reasoning = computed(() => {
|
||||
const runContext = session.value?.runContext;
|
||||
if (!Array.isArray(runContext)) return '';
|
||||
|
||||
const entry = [...runContext]
|
||||
.reverse()
|
||||
.find(item => item?.role === 'assistant' && item.content?.reasoning);
|
||||
return entry?.content?.reasoning || '';
|
||||
});
|
||||
|
||||
const STEP_ICONS = {
|
||||
tool: 'i-ph-wrench',
|
||||
handoff: 'i-ph-user-switch',
|
||||
};
|
||||
|
||||
const STEP_KEYPATHS = {
|
||||
tool: 'CONVERSATION.CAPTAIN_GENERATION.STEP_TOOL',
|
||||
handoff: 'CONVERSATION.CAPTAIN_GENERATION.STEP_HANDOFF',
|
||||
};
|
||||
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
|
||||
|
||||
// Model and credits are only surfaced to super admins and in development.
|
||||
const devDetails = computed(() => {
|
||||
if (!session.value) return null;
|
||||
if (!import.meta.env.DEV && !isSuperAdmin.value) return null;
|
||||
const model = t('CONVERSATION.CAPTAIN_GENERATION.MODEL', {
|
||||
model: session.value.llmModel,
|
||||
});
|
||||
const credits = t('CONVERSATION.CAPTAIN_GENERATION.CREDITS', {
|
||||
credits: session.value.creditsConsumed,
|
||||
});
|
||||
return `${model} · ${credits}`;
|
||||
});
|
||||
|
||||
// With the sparkle at the row start, the meta gets pushed to the opposite end;
|
||||
// without it, fall back to the message orientation.
|
||||
const rowLayoutClass = computed(() => {
|
||||
if (showSparkle.value) return 'justify-between';
|
||||
return orientation.value === ORIENTATION.LEFT
|
||||
? 'justify-start'
|
||||
: 'justify-end';
|
||||
});
|
||||
|
||||
// Blend the sparkle with the bubble background: amber on private notes,
|
||||
// slate everywhere else. Tokens adapt to dark mode on their own.
|
||||
const sparkleColorClass = computed(() => {
|
||||
if (variant.value === MESSAGE_VARIANTS.PRIVATE) {
|
||||
return isOpen.value
|
||||
? 'text-n-amber-12/80'
|
||||
: 'text-n-amber-12/40 hover:text-n-amber-12/70';
|
||||
}
|
||||
return isOpen.value
|
||||
? 'text-n-slate-12'
|
||||
: 'text-n-slate-11/60 hover:text-n-slate-12';
|
||||
});
|
||||
|
||||
const popoverAlign = computed(() =>
|
||||
orientation.value === ORIENTATION.LEFT ? 'start' : 'end'
|
||||
);
|
||||
|
||||
const prefetch = () => {
|
||||
store.dispatch('captainAgentSessions/fetch', {
|
||||
messageId: props.messageId,
|
||||
createdAt: createdAt.value,
|
||||
});
|
||||
};
|
||||
|
||||
const onPopoverShow = () => {
|
||||
isOpen.value = true;
|
||||
prefetch();
|
||||
};
|
||||
|
||||
const onPopoverHide = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5" :class="rowLayoutClass">
|
||||
<Popover
|
||||
v-if="showSparkle"
|
||||
:align="popoverAlign"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<button
|
||||
v-tooltip="t('CONVERSATION.CAPTAIN_GENERATION.TITLE')"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 p-0 bg-transparent border-0 cursor-pointer"
|
||||
:class="sparkleColorClass"
|
||||
@mouseenter="prefetch"
|
||||
@focus="prefetch"
|
||||
>
|
||||
<Icon icon="i-ph-sparkle-fill" class="size-3.5" />
|
||||
<span class="text-xs">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }}
|
||||
</span>
|
||||
</button>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-4 p-4 w-80">
|
||||
<span v-if="isLoading" class="text-xs text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }}
|
||||
</span>
|
||||
<span v-else-if="!session" class="text-xs text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }}
|
||||
</span>
|
||||
<template v-else>
|
||||
<div v-if="steps.length" class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="index"
|
||||
class="flex gap-2.5"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="flex items-center justify-center rounded-full size-5 bg-n-alpha-2 text-n-slate-11"
|
||||
>
|
||||
<Icon :icon="STEP_ICONS[step.type]" class="size-3" />
|
||||
</span>
|
||||
<span
|
||||
v-if="index < steps.length - 1"
|
||||
class="flex-1 w-px min-h-2 bg-n-weak"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col min-w-0 gap-0.5"
|
||||
:class="index < steps.length - 1 ? 'pb-3' : ''"
|
||||
>
|
||||
<I18nT
|
||||
:keypath="STEP_KEYPATHS[step.type]"
|
||||
tag="span"
|
||||
class="text-xs leading-5 text-n-slate-11"
|
||||
>
|
||||
<template #name>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{ step.name }}
|
||||
</span>
|
||||
</template>
|
||||
</I18nT>
|
||||
<span
|
||||
v-if="step.detail"
|
||||
class="text-xs text-n-slate-11 break-words"
|
||||
>
|
||||
{{ step.detail }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="citations.length" class="flex flex-col gap-2">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{
|
||||
t(
|
||||
'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY',
|
||||
citations.length
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<ul class="flex flex-col gap-1 m-0 list-disc ps-4">
|
||||
<li
|
||||
v-for="citation in citations"
|
||||
:key="citation.id"
|
||||
class="text-xs text-n-slate-12"
|
||||
>
|
||||
<a
|
||||
v-if="citation.link"
|
||||
:href="citation.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-n-blue-11 hover:underline"
|
||||
>
|
||||
{{ citation.title || citation.link }}
|
||||
</a>
|
||||
<span v-else>{{ citation.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="reasoning" class="flex flex-col gap-2">
|
||||
<span class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }}
|
||||
</span>
|
||||
<p class="m-0 text-xs leading-normal text-n-slate-12 break-words">
|
||||
{{ reasoning }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="devDetails" class="text-xs text-n-slate-11">
|
||||
{{ devDetails }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import MessageMeta from '../MessageMeta.vue';
|
||||
import CaptainGenerationDetails from '../CaptainGenerationDetails.vue';
|
||||
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
@@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants';
|
||||
|
||||
const props = defineProps({
|
||||
hideMeta: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { variant, orientation, inReplyTo, shouldGroupWithNext } =
|
||||
useMessageContext();
|
||||
const {
|
||||
variant,
|
||||
orientation,
|
||||
inReplyTo,
|
||||
shouldGroupWithNext,
|
||||
id,
|
||||
sender,
|
||||
senderType,
|
||||
} = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
|
||||
const isCaptainMessage = computed(
|
||||
() =>
|
||||
(sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT
|
||||
);
|
||||
|
||||
const metaColorClass = computed(() =>
|
||||
variant.value === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11'
|
||||
);
|
||||
|
||||
const emailMetaClass = computed(() =>
|
||||
variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : ''
|
||||
);
|
||||
|
||||
const varaintBaseMap = {
|
||||
[MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12',
|
||||
[MESSAGE_VARIANTS.PRIVATE]:
|
||||
@@ -114,16 +137,21 @@ const replyToPreview = computed(() => {
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
v-if="shouldShowMeta"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
variant === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '',
|
||||
variant === MESSAGE_VARIANTS.PRIVATE
|
||||
? 'text-n-amber-12/50'
|
||||
: 'text-n-slate-11',
|
||||
]"
|
||||
class="mt-2"
|
||||
/>
|
||||
<template v-if="shouldShowMeta">
|
||||
<CaptainGenerationDetails
|
||||
v-if="isCaptainMessage"
|
||||
:message-id="id"
|
||||
class="mt-2"
|
||||
>
|
||||
<template #meta>
|
||||
<MessageMeta :class="[emailMetaClass, metaColorClass]" />
|
||||
</template>
|
||||
</CaptainGenerationDetails>
|
||||
<MessageMeta
|
||||
v-else
|
||||
:class="[flexOrientationClass, emailMetaClass, metaColorClass]"
|
||||
class="mt-2"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -263,9 +263,9 @@ const handleCallBack = async () => {
|
||||
if (!canCallBack.value || isInitiatingCall.value) return;
|
||||
try {
|
||||
if (isWhatsapp.value) {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
conversationId.value
|
||||
);
|
||||
const response = await whatsappCallSession.initiateOutboundCall({
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return;
|
||||
// Permission template path returns no call id — show banner, no widget yet.
|
||||
if (!response?.id) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import {
|
||||
useBreakpoints,
|
||||
breakpointsTailwind,
|
||||
useEventListener,
|
||||
} from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
@@ -16,6 +20,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -41,8 +49,12 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const SCROLL_CLOSE_THRESHOLD = 24;
|
||||
const triggerTopAtOpen = ref(0);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
@@ -56,6 +68,22 @@ const hide = () => {
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
// The teleported popover tracks its trigger while ancestors scroll; allow
|
||||
// small drift (trackpad inertia), but close once the trigger moves further.
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
event => {
|
||||
if (!props.closeOnScroll || !showPopover.value) return;
|
||||
if (popoverRef.value?.contains(event.target)) return;
|
||||
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { h, ref, computed, onMounted, watch } from 'vue';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useStore } from 'vuex';
|
||||
@@ -43,7 +44,14 @@ const emit = defineEmits([
|
||||
]);
|
||||
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const store = useStore();
|
||||
|
||||
// Calls run on the enterprise-only API (cloud runs enterprise); hide the entry
|
||||
// on community so it doesn't lead to a dashboard/CTA the backend can't serve.
|
||||
const isCallsAvailable = computed(
|
||||
() => isOnChatwootCloud.value || isEnterprise
|
||||
);
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -503,7 +511,7 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
activeOn: [
|
||||
'captain_assistants_responses_index',
|
||||
'captain_assistants_responses_pending',
|
||||
'captain_assistants_faq_suggestions',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_responses_index',
|
||||
@@ -563,6 +571,17 @@ const menuItems = computed(() => {
|
||||
},
|
||||
],
|
||||
},
|
||||
...(isCallsAvailable.value
|
||||
? [
|
||||
{
|
||||
name: 'Calls',
|
||||
label: t('SIDEBAR.CALLS'),
|
||||
icon: 'i-lucide-phone',
|
||||
to: accountScopedRoute('calls_dashboard_index'),
|
||||
activeOn: ['calls_dashboard_index'],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Contacts',
|
||||
label: t('SIDEBAR.CONTACTS'),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { isWhatsAppComplete } from '@chatwoot/utils';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import {
|
||||
buildTemplateParameters,
|
||||
@@ -84,29 +85,10 @@ const renderedTemplate = computed(() => {
|
||||
return replaceTemplateVariables(bodyText.value, processedParams.value);
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaHeader.value) return false;
|
||||
|
||||
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasVariables.value && processedParams.value.body) {
|
||||
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
|
||||
value => !value
|
||||
);
|
||||
if (hasEmptyBodyVariable) return true;
|
||||
}
|
||||
|
||||
if (processedParams.value.buttons) {
|
||||
const hasEmptyButtonParameter = processedParams.value.buttons.some(
|
||||
button => !button.parameter
|
||||
);
|
||||
if (hasEmptyButtonParameter) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// Completeness validation is shared with the mobile app via @chatwoot/utils.
|
||||
const isFormInvalid = computed(
|
||||
() => !isWhatsAppComplete(props.template, processedParams.value)
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
|
||||
@@ -77,6 +77,15 @@ const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleFocusOut = event => {
|
||||
// Keep the menu open while focus stays inside it (e.g. the label search
|
||||
// input); close it once focus leaves the menu entirely.
|
||||
if (menuRef.value?.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
isLocked.value = false;
|
||||
});
|
||||
@@ -89,7 +98,7 @@ onUnmounted(() => {
|
||||
class="fixed outline-none z-[9999] cursor-pointer"
|
||||
:style="position"
|
||||
tabindex="0"
|
||||
@blur="handleClose"
|
||||
@focusout="handleFocusOut"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
imageResizeView,
|
||||
toggleMark,
|
||||
wrapInList,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
@@ -17,8 +19,6 @@ import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
|
||||
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
|
||||
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
|
||||
import { toggleMark } from 'prosemirror-commands';
|
||||
import { wrapInList } from 'prosemirror-schema-list';
|
||||
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { isEscape } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
@@ -69,9 +69,9 @@ const callButtonTooltip = computed(() =>
|
||||
const startWhatsappCall = async () => {
|
||||
if (whatsappCallSession.isInitiating.value) return;
|
||||
try {
|
||||
const response = await whatsappCallSession.initiateOutboundCall(
|
||||
props.chat.id
|
||||
);
|
||||
const response = await whatsappCallSession.initiateOutboundCall({
|
||||
conversationId: props.chat.id,
|
||||
});
|
||||
|
||||
// Composable returns LOCKED when init is already in flight or a call is
|
||||
// active; soft no-op so a parallel click doesn't trigger a banner.
|
||||
|
||||
@@ -33,9 +33,7 @@ import {
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants, {
|
||||
META_RESTRICTION_STATUS_URL,
|
||||
} from 'dashboard/constants/globals';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
@@ -95,7 +93,6 @@ export default {
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
@@ -173,13 +170,6 @@ export default {
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
isInstagramRestrictionBannerVisible() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
instagramRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
@@ -464,15 +454,7 @@ export default {
|
||||
>
|
||||
<div ref="topBannerRef">
|
||||
<Banner
|
||||
v-if="isInstagramRestrictionBannerVisible"
|
||||
color-scheme="warning"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
|
||||
:href-link="instagramRestrictionStatusUrl"
|
||||
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
|
||||
/>
|
||||
<Banner
|
||||
v-else-if="!currentChat.can_reply"
|
||||
v-if="!currentChat.can_reply"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="replyWindowBannerMessage"
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
} from 'dashboard/helper/agentHelper.js';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const MENU = {
|
||||
MARK_AS_READ: 'mark-as-read',
|
||||
@@ -31,6 +34,8 @@ export default {
|
||||
MenuItem,
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
NextInput,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
chatId: {
|
||||
@@ -87,6 +92,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
MENU,
|
||||
labelSearchQuery: '',
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
readOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||
@@ -216,6 +222,14 @@ export default {
|
||||
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
||||
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
filteredLabels() {
|
||||
const labels = this.labelSearchQuery
|
||||
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
|
||||
: this.labels;
|
||||
// Assigned labels first, keeping each group's existing order.
|
||||
const isAssigned = label => this.conversationLabels.includes(label.title);
|
||||
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
@@ -335,21 +349,49 @@ export default {
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
<div class="pb-1 w-[12.5rem]">
|
||||
<NextInput
|
||||
v-model="labelSearchQuery"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
custom-input-class="!ps-8 !text-xs"
|
||||
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
|
||||
/>
|
||||
</template>
|
||||
</NextInput>
|
||||
</div>
|
||||
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
|
||||
<MenuItem
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="!filteredLabels.length"
|
||||
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
|
||||
</p>
|
||||
</div>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.AGENT])"
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<fluent-icon
|
||||
v-if="variant === 'icon' && option.icon"
|
||||
:icon="option.icon"
|
||||
@@ -52,7 +52,7 @@ defineProps({
|
||||
<Icon
|
||||
v-if="variant === 'label-assigned'"
|
||||
icon="i-lucide-check"
|
||||
class="flex-shrink-0 size-3.5 mr-1"
|
||||
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -94,6 +94,32 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
||||
const namelessBot = {
|
||||
id: 91,
|
||||
name: null,
|
||||
assignee_type: 'AgentBot',
|
||||
availability_status: 'offline',
|
||||
};
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
||||
...allAgentsData,
|
||||
namelessBot,
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList();
|
||||
// access the computed to trigger evaluation
|
||||
expect(agentsList.value).toBeDefined();
|
||||
|
||||
const passedAgents =
|
||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
||||
expect(passedAgents).toContainEqual({
|
||||
...namelessBot,
|
||||
name: '-',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
@@ -53,7 +53,11 @@ export function useAgentsList(
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agents = (assignableAgents.value || []).map(agent =>
|
||||
!agent.name && agent.assignee_type === 'AgentBot'
|
||||
? { ...agent, name: '-' }
|
||||
: agent
|
||||
);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
|
||||
@@ -308,7 +308,8 @@ export function useWhatsappCallSession() {
|
||||
}
|
||||
};
|
||||
|
||||
const initiateOutboundCall = async conversationId => {
|
||||
// target: { conversationId } or { contactId, inboxId }
|
||||
const initiateOutboundCall = async target => {
|
||||
// Module-scoped lock + active-session guard so a second click — from the
|
||||
// same composable instance OR a different one (header vs contact panel)
|
||||
// OR while a call is already live — can't tear down the in-flight setup
|
||||
@@ -320,10 +321,7 @@ export function useWhatsappCallSession() {
|
||||
isInitiatingOutbound.value = true;
|
||||
try {
|
||||
const sdpOffer = await prepareOutboundOffer();
|
||||
const response = await WhatsappCallsAPI.initiate(
|
||||
conversationId,
|
||||
sdpOffer
|
||||
);
|
||||
const response = await WhatsappCallsAPI.initiate(target, sdpOffer);
|
||||
if (response?.id) {
|
||||
activeCallId = response.id;
|
||||
// A connect webhook that raced ahead of this response was buffered;
|
||||
@@ -354,7 +352,7 @@ export function useWhatsappCallSession() {
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_REQUESTED ||
|
||||
data?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.PERMISSION_PENDING
|
||||
) {
|
||||
return { status: data.status };
|
||||
return { status: data.status, conversation_id: data.conversation_id };
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
|
||||
@@ -78,5 +78,3 @@ export default {
|
||||
},
|
||||
};
|
||||
export const DEFAULT_REDIRECT_URL = '/app/';
|
||||
export const META_RESTRICTION_STATUS_URL =
|
||||
'https://status.chatwoot.com/incident/948346';
|
||||
|
||||
@@ -7,8 +7,7 @@ export const FEATURE_FLAGS = {
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION:
|
||||
'whatsapp_embedded_signup_inbox_creation',
|
||||
WHATSAPP_EMBEDDED_SIGNUP_FLOW: 'whatsapp_embedded_signup_inbox_creation',
|
||||
WHATSAPP_MANUAL_TRANSFER: 'whatsapp_manual_transfer',
|
||||
WHATSAPP_RECONFIGURE: 'whatsapp_reconfigure',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
|
||||
@@ -127,25 +127,8 @@ export const getHostNameFromURL = url => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts filename from a URL
|
||||
* @param {string} url - The URL to extract filename from
|
||||
* @returns {string} - The extracted filename or original URL if extraction fails
|
||||
*/
|
||||
export const extractFilenameFromUrl = url => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const filename = pathname.split('/').pop();
|
||||
return filename || url;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, try to extract filename using regex
|
||||
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
};
|
||||
// Shared with the mobile app via @chatwoot/utils.
|
||||
export { extractFilenameFromUrl } from '@chatwoot/utils';
|
||||
|
||||
/**
|
||||
* Normalizes a comma/newline separated list of domains
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
MessageMarkdownSerializer,
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
@@ -9,7 +11,6 @@ import * as Sentry from '@sentry/vue';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { InputRule, inputRules } from 'prosemirror-inputrules';
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
|
||||
@@ -26,6 +26,18 @@ describe('agentHelper', () => {
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when an agent has a null name', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: null, availability_status: 'offline' },
|
||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
||||
];
|
||||
|
||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
||||
expect(
|
||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
|
||||
@@ -156,12 +156,18 @@ describe('templateHelper', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle templates with no variables', () => {
|
||||
it('should handle templates with no variables but a media header', () => {
|
||||
const emptyTemplate = templates.find(
|
||||
t => t.name === 'no_variable_template'
|
||||
);
|
||||
const result = buildTemplateParameters(emptyTemplate, false);
|
||||
expect(result).toEqual({});
|
||||
const result = buildTemplateParameters(emptyTemplate);
|
||||
// hasMediaHeader is derived from the template, so the document header is kept.
|
||||
expect(result.body).toBeUndefined();
|
||||
expect(result.header).toEqual({
|
||||
media_url: '',
|
||||
media_type: 'document',
|
||||
media_name: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should build parameters for templates with multiple component types', () => {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Constants
|
||||
import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils';
|
||||
|
||||
// Constants and pure template helpers are shared with the mobile app via
|
||||
// @chatwoot/utils so the logic lives in one place.
|
||||
export {
|
||||
MEDIA_FORMATS,
|
||||
COMPONENT_TYPES,
|
||||
findComponentByType,
|
||||
processVariable,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'en';
|
||||
export const DEFAULT_CATEGORY = 'UTILITY';
|
||||
export const COMPONENT_TYPES = {
|
||||
HEADER: 'HEADER',
|
||||
BODY: 'BODY',
|
||||
BUTTONS: 'BUTTONS',
|
||||
};
|
||||
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
|
||||
|
||||
export const findComponentByType = (template, type) =>
|
||||
template.components?.find(component => component.type === type);
|
||||
|
||||
export const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
|
||||
export const allKeysRequired = value => {
|
||||
const keys = Object.keys(value);
|
||||
@@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
|
||||
const allVariables = {};
|
||||
|
||||
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
|
||||
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
|
||||
|
||||
if (!bodyComponent) return allVariables;
|
||||
|
||||
const templateString = bodyComponent.text;
|
||||
|
||||
// Process body variables
|
||||
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
|
||||
if (matchedVariables) {
|
||||
allVariables.body = {};
|
||||
matchedVariables.forEach(variable => {
|
||||
const key = processVariable(variable);
|
||||
allVariables.body[key] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaHeaderValue) {
|
||||
if (!allVariables.header) allVariables.header = {};
|
||||
allVariables.header.media_url = '';
|
||||
allVariables.header.media_type = headerComponent.format.toLowerCase();
|
||||
|
||||
// For document templates, include media_name field for filename support
|
||||
if (headerComponent.format.toLowerCase() === 'document') {
|
||||
allVariables.header.media_name = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Process button variables
|
||||
const buttonComponents = template.components.filter(
|
||||
component => component.type === COMPONENT_TYPES.BUTTONS
|
||||
);
|
||||
|
||||
buttonComponents.forEach(buttonComponent => {
|
||||
if (buttonComponent.buttons) {
|
||||
buttonComponent.buttons.forEach((button, index) => {
|
||||
// Handle URL buttons with variables
|
||||
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
|
||||
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
|
||||
if (buttonVars.length > 0) {
|
||||
if (!allVariables.buttons) allVariables.buttons = [];
|
||||
allVariables.buttons[index] = {
|
||||
type: 'url',
|
||||
parameter: '',
|
||||
url: button.url,
|
||||
variables: buttonVars.map(v => processVariable(v)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle copy code buttons
|
||||
if (button.type === 'COPY_CODE') {
|
||||
if (!allVariables.buttons) allVariables.buttons = [];
|
||||
allVariables.buttons[index] = {
|
||||
type: 'copy_code',
|
||||
parameter: '',
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return allVariables;
|
||||
};
|
||||
// The media-header flag is derived from the template inside the shared helper;
|
||||
// the second argument is kept for backwards-compatible call sites.
|
||||
export const buildTemplateParameters = template =>
|
||||
buildWhatsAppProcessedParams(template);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"CALLS_PAGE": {
|
||||
"HEADER": "Calls",
|
||||
"ALL_CALLS": "All Calls",
|
||||
"ALL_CALLS_COUNT": "All Calls ({count})",
|
||||
"EMPTY_STATE": "No calls found",
|
||||
"SETUP": {
|
||||
"TITLE": "Make and receive calls in one place",
|
||||
"SUBTITLE": "Set up a voice channel to start handling calls with your team. Every call, along with its recording, will appear here.",
|
||||
"ACTION": "Set up voice channel"
|
||||
},
|
||||
"FILTERS": {
|
||||
"MISSED": "Missed",
|
||||
"NO_REPLY": "No reply",
|
||||
"OTHER_ACTIVITY": "Other activity",
|
||||
"INCOMING": "Incoming",
|
||||
"OUTGOING": "Outgoing",
|
||||
"IN_PROGRESS": "In progress",
|
||||
"ASSIGNEE": "Assignee",
|
||||
"ALL_ASSIGNEES": "All assignees",
|
||||
"MORE_FILTERS": "More filters",
|
||||
"INBOX": "Inbox",
|
||||
"ALL_INBOXES": "All inboxes"
|
||||
},
|
||||
"STATUS": {
|
||||
"ONGOING": "Ongoing",
|
||||
"INCOMING": "Incoming",
|
||||
"OUTGOING": "Outgoing",
|
||||
"MISSED": "Missed",
|
||||
"NO_REPLY": "No reply",
|
||||
"FAILED": "Failed"
|
||||
},
|
||||
"ROW": {
|
||||
"PICKED_BY": "Picked by",
|
||||
"DIALED_BY": "Dialed by",
|
||||
"ANSWERED": "Answered",
|
||||
"RINGING": "Ringing",
|
||||
"IN_PROGRESS": "In progress",
|
||||
"NO_AGENT": "No agent answered this call",
|
||||
"NO_CONTACT_ANSWER": "Contact did not answer",
|
||||
"FAILED": "This call could not be connected",
|
||||
"YESTERDAY": "Yesterday"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,8 +44,6 @@
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
|
||||
"REPLYING_TO": "You are replying to:",
|
||||
"REMOVE_SELECTION": "Remove Selection",
|
||||
"DOWNLOAD": "Download",
|
||||
@@ -72,6 +70,20 @@
|
||||
"RATING_TITLE": "Rating",
|
||||
"FEEDBACK_TITLE": "Feedback",
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CAPTAIN_GENERATION": {
|
||||
"TITLE": "How was this reply generated?",
|
||||
"GENERATED_BY": "Generated by Captain",
|
||||
"LOADING": "Loading details…",
|
||||
"EMPTY": "No generation details available for this message.",
|
||||
"TIMELINE": "Generation steps",
|
||||
"STEP_TOOL": "Called {name}",
|
||||
"STEP_HANDOFF": "Handed off to {name}",
|
||||
"REASONING": "Reasoning",
|
||||
"SOURCES": "Knowledge base",
|
||||
"SOURCES_SUMMARY": "{count} result | {count} results",
|
||||
"MODEL": "Generated with {model}",
|
||||
"CREDITS": "Credits: {credits}"
|
||||
},
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
@@ -181,6 +193,8 @@
|
||||
},
|
||||
"ASSIGN_AGENT": "Assign agent",
|
||||
"ASSIGN_LABEL": "Assign label",
|
||||
"SEARCH_LABELS": "Search labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "Delete conversation",
|
||||
|
||||
@@ -58,9 +58,7 @@
|
||||
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
|
||||
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
|
||||
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
|
||||
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
|
||||
"STATUS_LINK": "View status update"
|
||||
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
|
||||
},
|
||||
"TIKTOK": {
|
||||
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
|
||||
|
||||
@@ -5,6 +5,7 @@ import attributesMgmt from './attributesMgmt.json';
|
||||
import auditLogs from './auditLogs.json';
|
||||
import automation from './automation.json';
|
||||
import bulkActions from './bulkActions.json';
|
||||
import calls from './calls.json';
|
||||
import campaign from './campaign.json';
|
||||
import cannedMgmt from './cannedMgmt.json';
|
||||
import chatlist from './chatlist.json';
|
||||
@@ -51,6 +52,7 @@ export default {
|
||||
...auditLogs,
|
||||
...automation,
|
||||
...bulkActions,
|
||||
...calls,
|
||||
...campaign,
|
||||
...cannedMgmt,
|
||||
...chatlist,
|
||||
|
||||
@@ -411,8 +411,8 @@
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"COVERAGE_BANNER": {
|
||||
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review FAQs",
|
||||
"TEXT": "{count} FAQ suggestions are ready for review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
|
||||
"ACTION": "Review suggestions",
|
||||
"DISMISS": "Dismiss"
|
||||
},
|
||||
"RANGES": {
|
||||
@@ -457,7 +457,7 @@
|
||||
"TITLE": "Knowledge coverage",
|
||||
"COVERAGE": "{pct}% approved",
|
||||
"APPROVED": "Approved FAQs",
|
||||
"PENDING": "Pending FAQs",
|
||||
"SUGGESTIONS": "FAQ suggestions",
|
||||
"DOCUMENTS": "Documents"
|
||||
},
|
||||
"LINKS": {
|
||||
@@ -1086,7 +1086,6 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -1095,12 +1094,10 @@
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"SEARCH_PLACEHOLDER": "Search FAQs...",
|
||||
"BULK_APPROVE_BUTTON": "Approve",
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_APPROVE": {
|
||||
"SUCCESS_MESSAGE": "FAQs approved successfully",
|
||||
"ERROR_MESSAGE": "There was an error approving the FAQs, please try again."
|
||||
"ERRORS": {
|
||||
"LOAD": "There was an error loading FAQs. Please try again."
|
||||
},
|
||||
"BULK_DELETE_BUTTON": "Delete",
|
||||
"BULK_DELETE": {
|
||||
"TITLE": "Delete FAQs?",
|
||||
"DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.",
|
||||
@@ -1115,20 +1112,9 @@
|
||||
"SUCCESS_MESSAGE": "FAQ deleted successfully",
|
||||
"ERROR_MESSAGE": "There was an error deleting the FAQ, please try again."
|
||||
},
|
||||
"FILTER": {
|
||||
"ASSISTANT": "Assistant: {selected}",
|
||||
"STATUS": "Status: {selected}",
|
||||
"ALL_ASSISTANTS": "All"
|
||||
},
|
||||
"STATUS": {
|
||||
"TITLE": "Status",
|
||||
"PENDING": "Pending",
|
||||
"APPROVED": "Approved",
|
||||
"ALL": "All"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
"SUGGESTIONS_BANNER": {
|
||||
"TITLE": "Captain grouped recurring customer questions into FAQ suggestions.",
|
||||
"ACTION": "Review suggestions"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
@@ -1161,7 +1147,6 @@
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
|
||||
"CLEAR_SEARCH": "Clear active filters",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
@@ -1170,6 +1155,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAQ_SUGGESTIONS": {
|
||||
"HEADER": "FAQ suggestions",
|
||||
"SEARCH_PLACEHOLDER": "Search suggestions...",
|
||||
"QUEUE_COUNT": "{count} suggestion to review | {count} suggestions to review",
|
||||
"QUEUE_HINT": "Ranked by customer demand",
|
||||
"SOURCE_COUNT": "{count} conversation | {count} conversations",
|
||||
"REVIEW": "Review sources",
|
||||
"APPROVE": "Approve",
|
||||
"APPROVE_FAQ": "Approve FAQ",
|
||||
"DISMISS": "Dismiss",
|
||||
"SAVE": "Save changes",
|
||||
"DETAILS": {
|
||||
"TITLE": "Review FAQ suggestion",
|
||||
"DESCRIPTION": "Captain grouped {count} conversation into this suggestion. Review the evidence and refine the FAQ before approval. | Captain grouped {count} conversations into this suggestion. Review the evidence and refine the FAQ before approval.",
|
||||
"SOURCES": "Source conversations",
|
||||
"SOURCE_PROGRESS": "{visible} of {total}",
|
||||
"NO_SOURCES": "No accessible source conversations were found.",
|
||||
"RETRY": "Retry"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "The review queue is clear",
|
||||
"SUBTITLE": "New reusable questions will appear here after Captain groups similar resolved conversations.",
|
||||
"CLEAR_SEARCH": "Clear search"
|
||||
},
|
||||
"SUCCESS": {
|
||||
"APPROVED": "FAQ approved and added to Captain's knowledge.",
|
||||
"DISMISSED": "Suggestion dismissed.",
|
||||
"SAVED": "Suggestion updated."
|
||||
},
|
||||
"ERRORS": {
|
||||
"LOAD": "There was an error loading FAQ suggestions. Please try again.",
|
||||
"LOAD_DETAILS": "There was an error loading the source conversations.",
|
||||
"APPROVE": "There was an error approving the FAQ. Please try again.",
|
||||
"DISMISS": "There was an error dismissing the suggestion. Please try again.",
|
||||
"SAVE": "There was an error saving the suggestion. Please try again."
|
||||
}
|
||||
},
|
||||
"INBOXES": {
|
||||
"HEADER": "Connected Inboxes",
|
||||
"ADD_NEW": "Connect a new inbox",
|
||||
|
||||
@@ -324,6 +324,7 @@
|
||||
"COMPANIES": "Companies",
|
||||
"ALL_COMPANIES": "All Companies",
|
||||
"CAPTAIN": "Captain",
|
||||
"CALLS": "Calls",
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_OVERVIEW": "Overview",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { until } from '@vueuse/core';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useCallHistoryStore } from 'dashboard/stores/callHistory';
|
||||
|
||||
import CallListItem from 'dashboard/components-next/Calls/CallListItem.vue';
|
||||
import CallsEmptyState from 'dashboard/components-next/Calls/CallsEmptyState.vue';
|
||||
import CallsFilterBar from 'dashboard/components-next/Calls/CallsFilterBar.vue';
|
||||
import { CALL_ACTIVITY_PARAMS } from 'dashboard/components-next/Calls/constants';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const RESULTS_PER_PAGE = 25;
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const callHistoryStore = useCallHistoryStore();
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
// CallFinder scopes non-admins to their own accepted calls, so the assignee
|
||||
// filter is only meaningful for admins; everyone else defaults to themselves.
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const voiceInboxes = computed(() => inboxes.value.filter(isVoiceCallEnabled));
|
||||
|
||||
const isVoiceEnabled = computed(
|
||||
() =>
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CHANNEL_VOICE
|
||||
) && voiceInboxes.value.length > 0
|
||||
);
|
||||
|
||||
const calls = computed(() => callHistoryStore.records);
|
||||
const meta = computed(() => callHistoryStore.meta);
|
||||
const isFetching = computed(() => callHistoryStore.uiFlags.isFetching);
|
||||
const accountUiFlags = useMapGetter('accounts/getUIFlags');
|
||||
|
||||
const isInitializing = ref(true);
|
||||
|
||||
// Filters are seeded from the URL so a shared link restores the same view.
|
||||
const activity = ref(
|
||||
CALL_ACTIVITY_PARAMS[route.query.activity] ? route.query.activity : null
|
||||
);
|
||||
|
||||
const assigneeId = ref(
|
||||
isAdmin.value ? Number(route.query.assignee_id) || null : currentUserId.value
|
||||
);
|
||||
const inboxId = ref(Number(route.query.inbox_id) || null);
|
||||
const currentPage = ref(Number(route.query.page) || 1);
|
||||
|
||||
const syncFiltersToUrl = () => {
|
||||
router.replace({
|
||||
query: {
|
||||
...(activity.value && { activity: activity.value }),
|
||||
...(isAdmin.value &&
|
||||
assigneeId.value && { assignee_id: assigneeId.value }),
|
||||
...(inboxId.value && { inbox_id: inboxId.value }),
|
||||
...(currentPage.value > 1 && { page: currentPage.value }),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCalls = async () => {
|
||||
syncFiltersToUrl();
|
||||
try {
|
||||
await callHistoryStore.fetchCalls({
|
||||
page: currentPage.value,
|
||||
...(CALL_ACTIVITY_PARAMS[activity.value] || {}),
|
||||
...(assigneeId.value ? { agent_id: assigneeId.value } : {}),
|
||||
...(inboxId.value ? { inbox_id: inboxId.value } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
useAlert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
watch([activity, assigneeId, inboxId], () => {
|
||||
currentPage.value = 1;
|
||||
fetchCalls();
|
||||
});
|
||||
|
||||
const onPageChange = page => {
|
||||
currentPage.value = page;
|
||||
fetchCalls();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
store.dispatch('inboxes/get'),
|
||||
until(() => accountUiFlags.value.isFetchingItem).toBe(false),
|
||||
]);
|
||||
if (!isVoiceEnabled.value) return;
|
||||
// Only admins see the assignee filter, so only they need the agent list.
|
||||
if (isAdmin.value) store.dispatch('agents/get');
|
||||
await fetchCalls();
|
||||
} finally {
|
||||
isInitializing.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isInitializing"
|
||||
class="flex items-center justify-center w-full h-full bg-n-surface-1"
|
||||
>
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<CallsEmptyState v-else-if="!isVoiceEnabled" />
|
||||
<section
|
||||
v-else
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<header class="px-6 pt-6 pb-4 shrink-0">
|
||||
<div class="w-full">
|
||||
<h1 class="text-xl font-medium text-n-slate-12">
|
||||
{{ t('CALLS_PAGE.HEADER') }}
|
||||
</h1>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full">
|
||||
<div v-if="isFetching" class="flex items-center justify-center py-16">
|
||||
<Spinner :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!calls.length"
|
||||
class="flex items-center justify-center py-16"
|
||||
>
|
||||
<span class="text-base text-n-slate-11">
|
||||
{{ t('CALLS_PAGE.EMPTY_STATE') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<CallListItem v-for="call in calls" :key="call.id" :call="call" />
|
||||
</template>
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="calls.length" class="sticky bottom-0 shrink-0">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="meta.count"
|
||||
:items-per-page="RESULTS_PER_PAGE"
|
||||
@update:current-page="onPageChange"
|
||||
/>
|
||||
</footer>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
ROLES,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import CallsIndex from './pages/CallsIndex.vue';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/calls'),
|
||||
name: 'calls_dashboard_index',
|
||||
component: CallsIndex,
|
||||
meta: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -26,21 +26,83 @@ const canDrilldown = computed(() => checkPermissions(['administrator']));
|
||||
const selectedRange = ref('this_month');
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const stats = ref(null);
|
||||
const metricStats = ref(null);
|
||||
const faqStats = ref(null);
|
||||
const isFetchingMetrics = ref(false);
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getStats({
|
||||
// Increments on every fetch so a response (or retry) from a superseded
|
||||
// range/assistant can't clobber the latest request's state.
|
||||
let metricsFetchToken = 0;
|
||||
let faqStatsFetchToken = 0;
|
||||
let metricsAbortController = null;
|
||||
let faqStatsAbortController = null;
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
metricsFetchToken += 1;
|
||||
const token = metricsFetchToken;
|
||||
metricsAbortController?.abort();
|
||||
metricsAbortController = new AbortController();
|
||||
const { signal } = metricsAbortController;
|
||||
metricStats.value = null;
|
||||
isFetchingMetrics.value = true;
|
||||
|
||||
const requestMetrics = () =>
|
||||
CaptainAssistant.getMetrics({
|
||||
assistantId: assistantId.value,
|
||||
range: selectedRange.value,
|
||||
signal,
|
||||
});
|
||||
stats.value = data;
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
({ data } = await requestMetrics());
|
||||
} catch {
|
||||
stats.value = null;
|
||||
// One silent retry before giving up, unless the request was aborted.
|
||||
try {
|
||||
if (token === metricsFetchToken && !signal.aborted)
|
||||
({ data } = await requestMetrics());
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== metricsFetchToken || signal.aborted) return;
|
||||
metricStats.value = data;
|
||||
isFetchingMetrics.value = false;
|
||||
};
|
||||
|
||||
const fetchFaqStats = async () => {
|
||||
faqStatsFetchToken += 1;
|
||||
const token = faqStatsFetchToken;
|
||||
faqStatsAbortController?.abort();
|
||||
faqStatsAbortController = new AbortController();
|
||||
const { signal } = faqStatsAbortController;
|
||||
faqStats.value = null;
|
||||
|
||||
try {
|
||||
const { data } = await CaptainAssistant.getFaqStats({
|
||||
assistantId: assistantId.value,
|
||||
signal,
|
||||
});
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = data;
|
||||
} catch {
|
||||
if (token === faqStatsFetchToken && !signal.aborted) faqStats.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
watch([selectedRange, assistantId], fetchStats, { immediate: true });
|
||||
const summaryStats = computed(() => {
|
||||
if (!metricStats.value || !faqStats.value) return null;
|
||||
|
||||
return { ...metricStats.value, knowledge: faqStats.value };
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
metricsAbortController?.abort();
|
||||
faqStatsAbortController?.abort();
|
||||
});
|
||||
|
||||
watch([selectedRange, assistantId], fetchMetrics, { immediate: true });
|
||||
watch(assistantId, fetchFaqStats, { immediate: true });
|
||||
|
||||
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
|
||||
// neutral, so we can colour the delta independently of its sign.
|
||||
@@ -60,7 +122,7 @@ const formatDuration = hours =>
|
||||
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
|
||||
|
||||
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
|
||||
const data = stats.value?.[statKey];
|
||||
const data = metricStats.value?.[statKey];
|
||||
if (!data) return { value: '—', trend: '', trendGood: null };
|
||||
|
||||
const sign = data.trend > 0 ? '+' : '';
|
||||
@@ -154,9 +216,9 @@ const closeDrilldown = () => {
|
||||
<div class="flex flex-col gap-6 pb-8">
|
||||
<InboxBanner />
|
||||
|
||||
<CoverageBanner :knowledge="stats?.knowledge" />
|
||||
<CoverageBanner :knowledge="faqStats ?? undefined" />
|
||||
|
||||
<WelcomeCard :range="selectedRange" />
|
||||
<WelcomeCard :range="selectedRange" :stats="summaryStats" />
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
|
||||
@@ -169,12 +231,15 @@ const closeDrilldown = () => {
|
||||
:trend="metric.trend"
|
||||
:hint="metric.hint"
|
||||
:trend-good="metric.trendGood"
|
||||
:clickable="canDrilldown && Boolean(metric.metric)"
|
||||
:loading="isFetchingMetrics"
|
||||
:clickable="
|
||||
canDrilldown && Boolean(metric.metric) && !isFetchingMetrics
|
||||
"
|
||||
@click="openDrilldown(metric)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<KnowledgeCard :knowledge="stats?.knowledge" />
|
||||
<KnowledgeCard :knowledge="faqStats ?? undefined" />
|
||||
|
||||
<QuickLinks />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
ROLES,
|
||||
} from 'dashboard/constants/permissions';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
|
||||
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
|
||||
@@ -15,7 +19,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import FaqSuggestionsIndex from './responses/FaqSuggestions.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
const meta = {
|
||||
@@ -24,6 +28,11 @@ const meta = {
|
||||
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
|
||||
};
|
||||
|
||||
const faqSuggestionsMeta = {
|
||||
...meta,
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
};
|
||||
|
||||
const metaCustomTools = {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
|
||||
@@ -79,11 +88,21 @@ const assistantRoutes = [
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/:assistantId/faqs/suggestions'
|
||||
),
|
||||
component: FaqSuggestionsIndex,
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
meta: faqSuggestionsMeta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_assistants_responses_pending',
|
||||
meta,
|
||||
redirect: to => ({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: to.params,
|
||||
query: to.query,
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils';
|
||||
import FaqSuggestions from './FaqSuggestions.vue';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
dispatch: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
route: null,
|
||||
getterValues: null,
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/api/captain/faqSuggestions', () => ({
|
||||
default: { get: mocks.apiGet },
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/store', async () => {
|
||||
const { ref } = await import('vue');
|
||||
mocks.getterValues = {
|
||||
'captainFaqSuggestions/getRecords': ref([]),
|
||||
'captainFaqSuggestions/getMeta': ref({ totalCount: 0, page: 1 }),
|
||||
'captainFaqSuggestions/getUIFlags': ref({
|
||||
fetchingList: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
useStore: () => ({ dispatch: mocks.dispatch }),
|
||||
useMapGetter: key => mocks.getterValues[key],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({ t: key => key }),
|
||||
}));
|
||||
|
||||
vi.mock('vue-router', async importOriginal => {
|
||||
const actual = await importOriginal();
|
||||
const { reactive } = await import('vue');
|
||||
mocks.route = reactive({
|
||||
params: { accountId: 1, assistantId: 1 },
|
||||
query: {},
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => ({ replace: mocks.replace }),
|
||||
};
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const PageLayoutStub = {
|
||||
template: '<div><slot name="body" /></div>',
|
||||
};
|
||||
|
||||
const FaqSuggestionCardStub = {
|
||||
props: ['suggestion'],
|
||||
template: '<div>{{ suggestion.question }}</div>',
|
||||
};
|
||||
|
||||
describe('FaqSuggestions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.route.params.assistantId = 1;
|
||||
mocks.route.query = {};
|
||||
mocks.getterValues['captainFaqSuggestions/getRecords'].value = [];
|
||||
mocks.getterValues['captainFaqSuggestions/getMeta'].value = {
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
};
|
||||
mocks.dispatch.mockImplementation((action, payload) => {
|
||||
if (action !== 'captainFaqSuggestions/setRecords') return;
|
||||
|
||||
mocks.getterValues['captainFaqSuggestions/getRecords'].value =
|
||||
payload.records;
|
||||
mocks.getterValues['captainFaqSuggestions/getMeta'].value = {
|
||||
totalCount: payload.meta.total_count,
|
||||
page: payload.meta.page,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the latest assistant results when an older request is superseded', async () => {
|
||||
const firstRequest = deferred();
|
||||
const secondRequest = deferred();
|
||||
const queued = [firstRequest, secondRequest];
|
||||
|
||||
mocks.apiGet.mockImplementation(({ signal }) => {
|
||||
const request = queued.shift();
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('canceled');
|
||||
error.name = 'CanceledError';
|
||||
request.reject(error);
|
||||
});
|
||||
return request.promise;
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(FaqSuggestions, {
|
||||
global: {
|
||||
mocks: { $t: key => key },
|
||||
stubs: {
|
||||
PageLayout: PageLayoutStub,
|
||||
FaqSuggestionCard: FaqSuggestionCardStub,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
// Switching assistants aborts the first request before it resolves.
|
||||
mocks.route.params.assistantId = 2;
|
||||
await flushPromises();
|
||||
|
||||
secondRequest.resolve({
|
||||
data: {
|
||||
payload: [{ id: 2, question: 'Current assistant' }],
|
||||
meta: { page: 1, total_count: 1 },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.text()).toContain('Current assistant');
|
||||
expect(wrapper.text()).not.toContain('Previous assistant');
|
||||
|
||||
wrapper.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FaqSuggestionCard from 'dashboard/components-next/captain/assistant/FaqSuggestionCard.vue';
|
||||
import FaqSuggestionReviewDialog from 'dashboard/components-next/captain/pageComponents/response/FaqSuggestionReviewDialog.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const suggestions = useMapGetter('captainFaqSuggestions/getRecords');
|
||||
const suggestionMeta = useMapGetter('captainFaqSuggestions/getMeta');
|
||||
const uiFlags = useMapGetter('captainFaqSuggestions/getUIFlags');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedSuggestion = ref(null);
|
||||
const reviewDialog = ref(null);
|
||||
const activeSuggestionId = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const isMutating = computed(
|
||||
() => uiFlags.value.updatingItem || uiFlags.value.deletingItem
|
||||
);
|
||||
const hasActiveFilters = computed(() => Boolean(searchQuery.value));
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
const updateURL = (page, search) => {
|
||||
const query = { page: page || 1 };
|
||||
if (search) query.search = search;
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchSuggestions = async (page = 1) => {
|
||||
updateURL(page, searchQuery.value);
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', true);
|
||||
|
||||
try {
|
||||
const response = await runListRequest(signal =>
|
||||
CaptainFaqSuggestionsAPI.get({
|
||||
page,
|
||||
search: searchQuery.value,
|
||||
assistantId: selectedAssistantId.value,
|
||||
signal,
|
||||
})
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
store.dispatch('captainFaqSuggestions/setRecords', {
|
||||
records: response.data.payload,
|
||||
meta: response.data.meta,
|
||||
});
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.LOAD'));
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshCurrentPage = () => {
|
||||
const currentPage = suggestionMeta.value.page || 1;
|
||||
const page =
|
||||
suggestions.value.length || currentPage === 1
|
||||
? currentPage
|
||||
: currentPage - 1;
|
||||
fetchSuggestions(page);
|
||||
};
|
||||
|
||||
const handleApprove = async suggestion => {
|
||||
activeSuggestionId.value = suggestion.id;
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/approve', {
|
||||
id: suggestion.id,
|
||||
question: suggestion.question,
|
||||
answer: suggestion.answer,
|
||||
});
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.APPROVED'));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.APPROVE'));
|
||||
} finally {
|
||||
activeSuggestionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async suggestion => {
|
||||
activeSuggestionId.value = suggestion.id;
|
||||
try {
|
||||
await store.dispatch('captainFaqSuggestions/dismiss', suggestion.id);
|
||||
useAlert(t('CAPTAIN.FAQ_SUGGESTIONS.SUCCESS.DISMISSED'));
|
||||
refreshCurrentPage();
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.FAQ_SUGGESTIONS.ERRORS.DISMISS'));
|
||||
} finally {
|
||||
activeSuggestionId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = suggestion => {
|
||||
selectedSuggestion.value = suggestion;
|
||||
nextTick(() => reviewDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleReviewClose = () => {
|
||||
selectedSuggestion.value = null;
|
||||
};
|
||||
|
||||
const handleResolved = () => {
|
||||
refreshCurrentPage();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(() => fetchSuggestions(1), 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
fetchSuggestions(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
searchQuery.value = route.query.search || '';
|
||||
fetchSuggestions(parseInt(route.query.page, 10) || 1);
|
||||
};
|
||||
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
() => {
|
||||
selectedSuggestion.value = null;
|
||||
activeSuggestionId.value = null;
|
||||
store.dispatch('captainFaqSuggestions/setRecords', {
|
||||
records: [],
|
||||
meta: { page: 1, total_count: 0 },
|
||||
});
|
||||
initializeFromURL();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispatch('captainFaqSuggestions/setFetchingList', false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="suggestionMeta.totalCount"
|
||||
:current-page="suggestionMeta.page"
|
||||
:header-title="$t('CAPTAIN.FAQ_SUGGESTIONS.HEADER')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!suggestions.length"
|
||||
:show-pagination-footer="!isFetching && !!suggestions.length"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="fetchSuggestions"
|
||||
>
|
||||
<template #search>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.FAQ_SUGGESTIONS.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<div
|
||||
v-if="suggestions.length"
|
||||
class="mb-2 flex items-center gap-2 text-sm text-n-slate-11"
|
||||
>
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{
|
||||
$t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_COUNT', {
|
||||
count: suggestionMeta.totalCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="border-s border-n-weak ps-2">
|
||||
{{ $t('CAPTAIN.FAQ_SUGGESTIONS.QUEUE_HINT') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.SUBTITLE')"
|
||||
:show-backdrop="false"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
:label="$t('CAPTAIN.FAQ_SUGGESTIONS.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4 pb-6">
|
||||
<FaqSuggestionCard
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion.id"
|
||||
:suggestion="suggestion"
|
||||
:is-loading="isMutating && activeSuggestionId === suggestion.id"
|
||||
@approve="handleApprove"
|
||||
@dismiss="handleDismiss"
|
||||
@review="handleReview"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<FaqSuggestionReviewDialog
|
||||
v-if="selectedSuggestion"
|
||||
ref="reviewDialog"
|
||||
:suggestion="selectedSuggestion"
|
||||
@close="handleReviewClose"
|
||||
@resolved="handleResolved"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { computed, onUnmounted, ref, nextTick, watch } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
@@ -41,7 +44,7 @@ const createDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => Number(route.params.assistantId));
|
||||
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
const suggestionCount = useMapGetter('captainFaqSuggestions/getOpenCount');
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
@@ -95,8 +98,10 @@ const updateURLWithFilters = (page, search) => {
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
const { run: runListRequest, abort: abortListRequest } = useAbortableRequest();
|
||||
|
||||
const fetchResponses = async (page = 1) => {
|
||||
const filterParams = { page };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
@@ -108,7 +113,24 @@ const fetchResponses = (page = 1) => {
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
store.dispatch('captainResponses/setFetchingList', true);
|
||||
|
||||
try {
|
||||
const response = await runListRequest(signal =>
|
||||
CaptainResponseAPI.get({ ...filterParams, signal })
|
||||
);
|
||||
|
||||
if (!response) return;
|
||||
|
||||
store.dispatch('captainResponses/setRecords', {
|
||||
records: response.data.payload,
|
||||
meta: response.data.meta,
|
||||
});
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
} catch (error) {
|
||||
useAlert(error?.message || t('CAPTAIN.RESPONSES.ERRORS.LOAD'));
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
}
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
@@ -181,24 +203,47 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const handleSearchInput = () => {
|
||||
abortListRequest();
|
||||
debouncedSearch();
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
searchQuery.value = route.query.search || '';
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_assistants_responses_pending' });
|
||||
const navigateToFaqSuggestions = () => {
|
||||
router.push({
|
||||
name: 'captain_assistants_faq_suggestions',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
store.dispatch(
|
||||
'captainResponses/fetchPendingCount',
|
||||
selectedAssistantId.value
|
||||
);
|
||||
watch(
|
||||
selectedAssistantId,
|
||||
() => {
|
||||
selectedResponse.value = null;
|
||||
bulkSelectedIds.value = new Set();
|
||||
store.dispatch('captainResponses/setRecords', {
|
||||
records: [],
|
||||
meta: { page: 1, total_count: 0 },
|
||||
});
|
||||
initializeFromURL();
|
||||
store.dispatch(
|
||||
'captainFaqSuggestions/fetchOpenCount',
|
||||
selectedAssistantId.value
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
store.dispatch('captainResponses/setFetchingList', false);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -240,7 +285,7 @@ onMounted(() => {
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
@input="handleSearchInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -271,13 +316,13 @@ onMounted(() => {
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
v-if="suggestionCount > 0"
|
||||
color="blue"
|
||||
class="mb-4 -mt-3"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.ACTION')"
|
||||
@action="navigateToFaqSuggestions"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
{{ $t('CAPTAIN.RESPONSES.SUGGESTIONS_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = computed(() => route.params.assistantId);
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
// Filter out approved responses in pending view
|
||||
const filteredResponses = computed(() =>
|
||||
responses.value.filter(response => response.status !== 'approved')
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
}
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigationAction = ({ id, type }) => {
|
||||
if (type === 'Conversation') {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const updateURLWithFilters = (page, search) => {
|
||||
const query = {
|
||||
page: page || 1,
|
||||
};
|
||||
|
||||
if (search) {
|
||||
query.search = search;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchResponses(pageToFetch);
|
||||
} else {
|
||||
fetchResponses(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const hadSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (hadSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return Boolean(searchQuery.value);
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
fetchResponses(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeFromURL();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:show-know-more="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #search>
|
||||
<div
|
||||
v-if="bulkSelectedIds.size === 0"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="filteredResponses"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
class="w-fit"
|
||||
:class="{
|
||||
'mb-2': bulkSelectedIds.size > 0,
|
||||
}"
|
||||
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
|
||||
>
|
||||
<template #secondaryActions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState
|
||||
variant="pending"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
:entity="selectedResponse"
|
||||
type="Responses"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="AssistantResponse"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
import settings from './settings/settings.routes';
|
||||
import conversation from './conversation/conversation.routes';
|
||||
import { routes as searchRoutes } from '../../modules/search/search.routes';
|
||||
import { routes as callRoutes } from './calls/routes';
|
||||
import { routes as contactRoutes } from './contacts/routes';
|
||||
import { routes as companyRoutes } from './companies/routes';
|
||||
import { routes as notificationRoutes } from './notifications/routes';
|
||||
@@ -25,6 +26,7 @@ export default {
|
||||
...inboxRoutes,
|
||||
...conversation.routes,
|
||||
...settings.routes,
|
||||
...callRoutes,
|
||||
...contactRoutes,
|
||||
...companyRoutes,
|
||||
...searchRoutes,
|
||||
|
||||
+1
-3
@@ -18,9 +18,7 @@ export function useChannelConfig() {
|
||||
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||
whatsapp: () =>
|
||||
(!isOnChatwootCloud.value ||
|
||||
isCloudFeatureEnabled(
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
|
||||
)) &&
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW)) &&
|
||||
Boolean(installationConfig.whatsappAppId) &&
|
||||
installationConfig.whatsappAppId !== 'none' &&
|
||||
Boolean(installationConfig.whatsappConfigurationId),
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
||||
<BaseTableCell class="max-w-0">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:name="bot.name || ''"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
class="flex-shrink-0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -14,7 +14,7 @@ const { accountId, currentAccount } = useAccount();
|
||||
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const enabledFeatures = ref({});
|
||||
const enabledFeatures = computed(() => currentAccount.value?.features || {});
|
||||
|
||||
const hasTiktokConfigured = computed(() => {
|
||||
return window.chatwootConfig?.tiktokAppId;
|
||||
@@ -105,10 +105,6 @@ const channelList = computed(() => {
|
||||
return channels;
|
||||
});
|
||||
|
||||
const initializeEnabledFeatures = async () => {
|
||||
enabledFeatures.value = currentAccount.value.features;
|
||||
};
|
||||
|
||||
const initChannelAuth = channel => {
|
||||
const params = {
|
||||
sub_page: channel,
|
||||
@@ -116,10 +112,6 @@ const initChannelAuth = channel => {
|
||||
};
|
||||
router.push({ name: 'settings_inboxes_page_channel', params });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeEnabledFeatures();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -4,8 +4,6 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
|
||||
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
|
||||
@@ -46,11 +44,9 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
|
||||
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
BotConfiguration,
|
||||
CollaboratorsPage,
|
||||
ConfigurationPage,
|
||||
@@ -84,7 +80,6 @@ export default {
|
||||
WhatsappManualMigrationBanner,
|
||||
Widget,
|
||||
AccessToken,
|
||||
Icon,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
@@ -348,12 +343,6 @@ export default {
|
||||
instagramUnauthorized() {
|
||||
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
showInstagramRestrictionSettingsBanner() {
|
||||
return this.isOnChatwootCloud && this.isAnInstagramChannel;
|
||||
},
|
||||
metaRestrictionStatusUrl() {
|
||||
return META_RESTRICTION_STATUS_URL;
|
||||
},
|
||||
tiktokUnauthorized() {
|
||||
return this.isATiktokChannel && this.inbox.reauthorization_required;
|
||||
},
|
||||
@@ -390,6 +379,11 @@ export default {
|
||||
return (
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
(!this.isOnChatwootCloud ||
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
)) &&
|
||||
this.inbox.reauthorization_required
|
||||
);
|
||||
},
|
||||
@@ -815,29 +809,6 @@ export default {
|
||||
:class="bannerMaxWidth"
|
||||
@start="openWhatsAppManualMigrationDialog"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showInstagramRestrictionSettingsBanner"
|
||||
color="amber"
|
||||
class="mx-6 mb-4 max-w-4xl"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-start">
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="flex-shrink-0 size-4 mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
|
||||
<a
|
||||
:href="metaRestrictionStatusUrl"
|
||||
class="link underline"
|
||||
rel="noopener noreferrer nofollow"
|
||||
target="_blank"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div
|
||||
v-if="selectedTabKey === 'inbox-settings'"
|
||||
|
||||
@@ -42,9 +42,7 @@ const shouldShowWhatsappEmbeddedSignup = computed(() => {
|
||||
selectedProvider.value === PROVIDER_TYPES.WHATSAPP &&
|
||||
hasWhatsappAppId.value &&
|
||||
(!isOnChatwootCloud.value ||
|
||||
isCloudFeatureEnabled(
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_INBOX_CREATION
|
||||
))
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW))
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+5
-3
@@ -3,6 +3,7 @@ import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
import { useBranding } from 'shared/composables/useBranding';
|
||||
|
||||
const props = defineProps({
|
||||
senderNameType: {
|
||||
@@ -22,6 +23,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
const senderNameKeyOptions = computed(() => [
|
||||
{
|
||||
@@ -30,7 +32,7 @@ const senderNameKeyOptions = computed(() => [
|
||||
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
|
||||
preview: {
|
||||
senderName: 'Smith',
|
||||
businessName: 'Chatwoot',
|
||||
businessName: replaceInstallationName('Chatwoot'),
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
@@ -40,7 +42,7 @@ const senderNameKeyOptions = computed(() => [
|
||||
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
|
||||
preview: {
|
||||
senderName: '',
|
||||
businessName: 'Chatwoot',
|
||||
businessName: replaceInstallationName('Chatwoot'),
|
||||
email: '<support@yourbusiness.com>',
|
||||
},
|
||||
},
|
||||
@@ -51,7 +53,7 @@ const isKeyOptionFriendly = key => key === 'friendly';
|
||||
const userName = keyOption =>
|
||||
isKeyOptionFriendly(keyOption.key)
|
||||
? keyOption.preview.senderName
|
||||
: keyOption.preview.businessName;
|
||||
: props.businessName || keyOption.preview.businessName;
|
||||
|
||||
const toggleSenderNameType = key => {
|
||||
emit('update', key);
|
||||
|
||||
+4
-1
@@ -56,6 +56,7 @@ export default {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
isEmbeddedSignupWhatsApp() {
|
||||
return this.inbox.provider_config?.source === 'embedded_signup';
|
||||
@@ -65,7 +66,9 @@ export default {
|
||||
this.isEmbeddedSignupWhatsApp &&
|
||||
this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_RECONFIGURE
|
||||
this.isOnChatwootCloud
|
||||
? FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP_FLOW
|
||||
: FEATURE_FLAGS.WHATSAPP_RECONFIGURE
|
||||
)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
const SET_SESSION = 'SET_SESSION';
|
||||
const SET_FETCHING = 'SET_FETCHING';
|
||||
|
||||
// Session capture runs right after the message is broadcast (and well after,
|
||||
// for handoff notes created mid-run), so a 404 on a fresh message may just
|
||||
// mean the session isn't written yet. Skip caching those so a later
|
||||
// hover/click retries; older misses are permanent (V1 messages, failed runs).
|
||||
const RECENT_MESSAGE_WINDOW_SECONDS = 60;
|
||||
|
||||
// Caches Captain agent-session metadata per message id. A missing session
|
||||
// (404) is cached as null so the UI shows an empty state without refetching.
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
sessions: {},
|
||||
fetchingIds: [],
|
||||
},
|
||||
getters: {
|
||||
getSessionByMessageId: state => messageId => state.sessions[messageId],
|
||||
isFetching: state => messageId => state.fetchingIds.includes(messageId),
|
||||
hasFetched: state => messageId => messageId in state.sessions,
|
||||
},
|
||||
actions: {
|
||||
fetch: async ({ state, commit }, { messageId, createdAt }) => {
|
||||
if (messageId in state.sessions) return;
|
||||
if (state.fetchingIds.includes(messageId)) return;
|
||||
|
||||
commit(SET_FETCHING, { messageId, isFetching: true });
|
||||
try {
|
||||
const { data } = await CaptainAgentSessionsAPI.show(messageId);
|
||||
commit(SET_SESSION, {
|
||||
messageId,
|
||||
session: camelcaseKeys(data, { deep: true }),
|
||||
});
|
||||
} catch (error) {
|
||||
const isRecentMessage =
|
||||
createdAt &&
|
||||
Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS;
|
||||
// Only a 404 means "no session exists"; transient failures (5xx,
|
||||
// network errors) stay uncached so a later hover retries.
|
||||
if (error.response?.status === 404 && !isRecentMessage) {
|
||||
commit(SET_SESSION, { messageId, session: null });
|
||||
}
|
||||
} finally {
|
||||
commit(SET_FETCHING, { messageId, isFetching: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
[SET_SESSION](state, { messageId, session }) {
|
||||
state.sessions = { ...state.sessions, [messageId]: session };
|
||||
},
|
||||
[SET_FETCHING](state, { messageId, isFetching }) {
|
||||
state.fetchingIds = isFetching
|
||||
? [...state.fetchingIds, messageId]
|
||||
: state.fetchingIds.filter(id => id !== messageId);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -48,20 +48,6 @@ export default createStore({
|
||||
return response;
|
||||
},
|
||||
|
||||
handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantResponse',
|
||||
actionType: 'approve',
|
||||
ids,
|
||||
});
|
||||
|
||||
// Update response store after successful API call
|
||||
await dispatch('captainResponses/updateBulkResponses', response, {
|
||||
root: true,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
|
||||
handleBulkSync: async function handleBulkSync({ dispatch }, { ids }) {
|
||||
const response = await dispatch('processBulkAction', {
|
||||
type: 'AssistantDocument',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import CaptainFaqSuggestionsAPI from 'dashboard/api/captain/faqSuggestions';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
const SET_OPEN_COUNT = 'SET_OPEN_COUNT';
|
||||
let openCountRequestId = 0;
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainFaqSuggestion',
|
||||
API: CaptainFaqSuggestionsAPI,
|
||||
getters: {
|
||||
getRecords: state => state.records,
|
||||
getOpenCount: state => state.meta.openCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_OPEN_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
openCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
setFetchingList({ commit }, isFetching) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
|
||||
},
|
||||
setRecords({ commit }, { records, meta }) {
|
||||
commit(mutations.SET, records);
|
||||
commit(mutations.SET_META, meta);
|
||||
},
|
||||
approve: async ({ commit }, { id, question, answer }) => {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await CaptainFaqSuggestionsAPI.approve(id, {
|
||||
question,
|
||||
answer,
|
||||
});
|
||||
commit(mutations.DELETE, id);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutations.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
},
|
||||
dismiss: async ({ commit }, id) => {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await CaptainFaqSuggestionsAPI.dismiss(id);
|
||||
commit(mutations.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutations.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
},
|
||||
fetchOpenCount: async ({ commit }, assistantId) => {
|
||||
openCountRequestId += 1;
|
||||
const requestId = openCountRequestId;
|
||||
commit(SET_OPEN_COUNT, 0);
|
||||
|
||||
try {
|
||||
const response = await CaptainFaqSuggestionsAPI.get({
|
||||
assistantId,
|
||||
page: 1,
|
||||
});
|
||||
if (requestId !== openCountRequestId) return;
|
||||
|
||||
commit(SET_OPEN_COUNT, response.data?.meta?.total_count || 0);
|
||||
} catch {
|
||||
if (requestId === openCountRequestId) commit(SET_OPEN_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -1,58 +1,22 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainResponse',
|
||||
API: CaptainResponseAPI,
|
||||
getters: {
|
||||
getPendingCount: state => state.meta.pendingCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_PENDING_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
pendingCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
setFetchingList({ commit }, isFetching) {
|
||||
commit(mutations.SET_UI_FLAG, { fetchingList: isFetching });
|
||||
},
|
||||
setRecords({ commit }, { records, meta }) {
|
||||
commit(mutations.SET, records);
|
||||
commit(mutations.SET_META, meta);
|
||||
},
|
||||
removeBulkResponses: ({ commit, state }, ids) => {
|
||||
const updatedRecords = state.records.filter(
|
||||
record => !ids.includes(record.id)
|
||||
);
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
updateBulkResponses: ({ commit, state }, approvedResponses) => {
|
||||
// Create a map of updated responses for faster lookup
|
||||
const updatedResponsesMap = approvedResponses.reduce((map, response) => {
|
||||
map[response.id] = response;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
// Update existing records with updated data
|
||||
const updatedRecords = state.records.map(record => {
|
||||
if (updatedResponsesMap[record.id]) {
|
||||
return updatedResponsesMap[record.id]; // Replace with the updated response
|
||||
}
|
||||
return record;
|
||||
});
|
||||
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
fetchPendingCount: async ({ commit }, assistantId) => {
|
||||
try {
|
||||
const response = await CaptainResponseAPI.get({
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
assistantId,
|
||||
});
|
||||
const count = response.data?.meta?.total_count || 0;
|
||||
commit(SET_PENDING_COUNT, count);
|
||||
} catch (error) {
|
||||
commit(SET_PENDING_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -50,9 +50,11 @@ import teamMembers from './modules/teamMembers';
|
||||
import teams from './modules/teams';
|
||||
import userNotificationSettings from './modules/userNotificationSettings';
|
||||
import webhooks from './modules/webhooks';
|
||||
import captainAgentSessions from './captain/agentSessions';
|
||||
import captainAssistants from './captain/assistant';
|
||||
import captainDocuments from './captain/document';
|
||||
import captainResponses from './captain/response';
|
||||
import captainFaqSuggestions from './captain/faqSuggestions';
|
||||
import captainInboxes from './captain/inboxes';
|
||||
import captainBulkActions from './captain/bulkActions';
|
||||
import copilotThreads from './captain/copilotThreads';
|
||||
@@ -115,9 +117,11 @@ export default createStore({
|
||||
teams,
|
||||
userNotificationSettings,
|
||||
webhooks,
|
||||
captainAgentSessions,
|
||||
captainAssistants,
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
captainFaqSuggestions,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
copilotThreads,
|
||||
|
||||
@@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel';
|
||||
import TwilioChannel from '../../api/channel/twilioChannel';
|
||||
import WhatsappChannel from '../../api/channel/whatsappChannel';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
import { isSendableTemplate } from '@chatwoot/utils';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
@@ -67,45 +68,8 @@ export const getters = {
|
||||
return [];
|
||||
}
|
||||
|
||||
return templates.filter(template => {
|
||||
// Ensure template has required properties
|
||||
if (!template || !template.status || !template.components) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only show approved templates
|
||||
if (template.status.toLowerCase() !== 'approved') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out authentication templates
|
||||
if (template.category === 'AUTHENTICATION') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
|
||||
if (
|
||||
template.name &&
|
||||
template.name.startsWith('customer_satisfaction_survey')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
|
||||
component.type
|
||||
) ||
|
||||
(component.type === 'HEADER' && component.format === 'LOCATION')
|
||||
);
|
||||
|
||||
if (hasUnsupportedComponents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
// Sendable-template filtering is shared with the mobile app via @chatwoot/utils.
|
||||
return templates.filter(isSendableTemplate);
|
||||
},
|
||||
getNewConversationInboxes($state) {
|
||||
return $state.records.filter(inbox => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
import CallsAPI from 'dashboard/api/calls';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useCallHistoryStore = defineStore('callHistory', {
|
||||
state: () => ({
|
||||
records: [],
|
||||
meta: { count: 0, currentPage: 1, totalPages: 0 },
|
||||
uiFlags: { isFetching: false },
|
||||
fetchRequestToken: 0,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
async fetchCalls(params = {}) {
|
||||
this.uiFlags.isFetching = true;
|
||||
this.fetchRequestToken += 1;
|
||||
const requestToken = this.fetchRequestToken;
|
||||
try {
|
||||
const { data } = await CallsAPI.get(params);
|
||||
// A newer fetch (filter/page change) superseded this one; drop the result.
|
||||
if (this.fetchRequestToken !== requestToken) return this.records;
|
||||
this.records = camelcaseKeys(data.payload, { deep: true });
|
||||
this.meta = camelcaseKeys(data.meta);
|
||||
return this.records;
|
||||
} catch (error) {
|
||||
// Don't surface errors from a fetch that a newer request already replaced.
|
||||
if (this.fetchRequestToken !== requestToken) return this.records;
|
||||
// Drop the previous results so stale rows aren't shown under the new view.
|
||||
this.records = [];
|
||||
this.meta = { count: 0, currentPage: 1, totalPages: 0 };
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
if (this.fetchRequestToken === requestToken) {
|
||||
this.uiFlags.isFetching = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CallsAPI from 'dashboard/api/calls';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import { useCallHistoryStore } from '../callHistory';
|
||||
|
||||
vi.mock('dashboard/api/calls', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(error => error),
|
||||
}));
|
||||
|
||||
const createDeferred = () => {
|
||||
let resolve;
|
||||
const promise = new Promise(res => {
|
||||
resolve = res;
|
||||
});
|
||||
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
const buildResponse = (payload, meta) => ({ data: { payload, meta } });
|
||||
|
||||
describe('callHistory store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('fetches calls and stores camelized records and meta', async () => {
|
||||
CallsAPI.get.mockResolvedValue(
|
||||
buildResponse(
|
||||
[{ id: 1, recording_url: 'rec.mp3', contact: { phone_number: '+1' } }],
|
||||
{ count: 44, current_page: 1, total_pages: 2 }
|
||||
)
|
||||
);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
await store.fetchCalls({ page: 1, status: 'no-answer' });
|
||||
|
||||
expect(CallsAPI.get).toHaveBeenCalledWith({ page: 1, status: 'no-answer' });
|
||||
expect(store.records).toEqual([
|
||||
{ id: 1, recordingUrl: 'rec.mp3', contact: { phoneNumber: '+1' } },
|
||||
]);
|
||||
expect(store.meta).toEqual({ count: 44, currentPage: 1, totalPages: 2 });
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a superseded response that resolves after the latest one', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
CallsAPI.get
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
const staleFetch = store.fetchCalls({ page: 1 });
|
||||
const currentFetch = store.fetchCalls({ page: 2 });
|
||||
|
||||
secondRequest.resolve(
|
||||
buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
|
||||
);
|
||||
await currentFetch;
|
||||
|
||||
firstRequest.resolve(
|
||||
buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
|
||||
);
|
||||
await staleFetch;
|
||||
|
||||
expect(store.records).toEqual([{ id: 2 }]);
|
||||
expect(store.meta.count).toBe(1);
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps fetching state when a superseded response resolves first', async () => {
|
||||
const firstRequest = createDeferred();
|
||||
const secondRequest = createDeferred();
|
||||
CallsAPI.get
|
||||
.mockImplementationOnce(() => firstRequest.promise)
|
||||
.mockImplementationOnce(() => secondRequest.promise);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
const staleFetch = store.fetchCalls({ page: 1 });
|
||||
const currentFetch = store.fetchCalls({ page: 2 });
|
||||
|
||||
firstRequest.resolve(
|
||||
buildResponse([{ id: 1 }], { count: 99, current_page: 1, total_pages: 9 })
|
||||
);
|
||||
await staleFetch;
|
||||
|
||||
expect(store.records).toEqual([]);
|
||||
expect(store.uiFlags.isFetching).toBe(true);
|
||||
|
||||
secondRequest.resolve(
|
||||
buildResponse([{ id: 2 }], { count: 1, current_page: 2, total_pages: 2 })
|
||||
);
|
||||
await currentFetch;
|
||||
|
||||
expect(store.records).toEqual([{ id: 2 }]);
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('surfaces the error and resets fetching state on failure', async () => {
|
||||
const error = new Error('Request failed');
|
||||
CallsAPI.get.mockRejectedValue(error);
|
||||
const store = useCallHistoryStore();
|
||||
|
||||
await store.fetchCalls();
|
||||
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(store.records).toEqual([]);
|
||||
expect(store.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { useCompaniesStore } from './companies';
|
||||
import { useCompaniesStore } from '../companies';
|
||||
|
||||
vi.mock('dashboard/api/companies', () => ({
|
||||
default: {
|
||||
@@ -68,6 +68,10 @@ const isAgentBot = computed(
|
||||
() => props.selectedItem?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const selectedItemName = computed(() =>
|
||||
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
||||
);
|
||||
|
||||
const selectedThumbnail = computed(
|
||||
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
||||
);
|
||||
@@ -95,16 +99,16 @@ const selectedThumbnail = computed(
|
||||
<h4
|
||||
v-else
|
||||
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||
:title="selectedItem.name"
|
||||
:title="selectedItemName"
|
||||
>
|
||||
{{ selectedItem.name }}
|
||||
{{ selectedItemName }}
|
||||
</h4>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
||||
:src="selectedThumbnail"
|
||||
:status="selectedItem.availability_status"
|
||||
:name="selectedItem.name"
|
||||
:name="selectedItemName"
|
||||
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
||||
:size="24"
|
||||
hide-offline-status
|
||||
|
||||
@@ -53,7 +53,9 @@ export default {
|
||||
computed: {
|
||||
filteredOptions() {
|
||||
return this.options.filter(option => {
|
||||
return option.name.toLowerCase().includes(this.search.toLowerCase());
|
||||
return (option.name || '')
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
noResult() {
|
||||
|
||||
@@ -73,13 +73,13 @@ describe('useBranding', () => {
|
||||
expect(result).toBe('Welcome to our platform');
|
||||
});
|
||||
|
||||
it('should be case-sensitive for "Chatwoot"', () => {
|
||||
it('should replace "Chatwoot" regardless of casing', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName(
|
||||
'Welcome to chatwoot and CHATWOOT'
|
||||
'Welcome to chatwoot, Chatwoot and CHATWOOT'
|
||||
);
|
||||
|
||||
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
|
||||
expect(result).toBe('Welcome to MyCompany, MyCompany and MyCompany');
|
||||
});
|
||||
|
||||
it('should handle special characters in installation name', () => {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
export function useBranding() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
/**
|
||||
* Replaces "Chatwoot" in text with the installation name from global config
|
||||
* Replaces "Chatwoot" (any casing) in text with the installation name from
|
||||
* global config
|
||||
* @param {string} text - The text to process
|
||||
* @returns {string} - Text with "Chatwoot" replaced by installation name
|
||||
*/
|
||||
@@ -17,7 +18,7 @@ export function useBranding() {
|
||||
const installationName = globalConfig.value?.installationName;
|
||||
if (!installationName) return text;
|
||||
|
||||
return text.replace(/Chatwoot/g, installationName);
|
||||
return text.replace(/chatwoot/gi, installationName);
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
messageStamp,
|
||||
messageTimestamp,
|
||||
dynamicTime,
|
||||
dateFormat,
|
||||
shortTimestamp,
|
||||
dynamicTime,
|
||||
getDayDifferenceFromNow,
|
||||
hasOneDayPassed,
|
||||
messageStamp,
|
||||
messageTimestamp,
|
||||
relativeDayTimestamp,
|
||||
shortTimestamp,
|
||||
} from 'shared/helpers/timeHelper';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -37,6 +38,33 @@ describe('#messageTimestamp', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#relativeDayTimestamp', () => {
|
||||
// System time is mocked to May 5, 2023 00:00 UTC.
|
||||
const toUnix = date => Math.floor(date / 1000);
|
||||
|
||||
it('returns the time for timestamps from today', () => {
|
||||
const today = toUnix(Date.UTC(2023, 4, 5, 15, 35, 0));
|
||||
expect(relativeDayTimestamp(today, 'Yesterday')).toEqual('3:35 PM');
|
||||
});
|
||||
|
||||
it('returns the supplied label for timestamps from yesterday', () => {
|
||||
const yesterday = toUnix(Date.UTC(2023, 4, 4, 9, 0, 0));
|
||||
expect(relativeDayTimestamp(yesterday, 'Yesterday')).toEqual('Yesterday');
|
||||
});
|
||||
|
||||
it('returns a day and month for older timestamps in the current year', () => {
|
||||
const earlierThisYear = toUnix(Date.UTC(2023, 1, 10, 12, 0, 0));
|
||||
expect(relativeDayTimestamp(earlierThisYear, 'Yesterday')).toEqual(
|
||||
'Feb 10'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a full date for timestamps from a previous year', () => {
|
||||
const lastYear = toUnix(Date.UTC(2021, 1, 10, 12, 0, 0));
|
||||
expect(relativeDayTimestamp(lastYear, 'Yesterday')).toEqual('Feb 10, 2021');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#dynamicTime', () => {
|
||||
it('returns correct value', () => {
|
||||
Date.now = vi.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
format,
|
||||
isSameYear,
|
||||
isThisYear,
|
||||
isToday,
|
||||
isYesterday,
|
||||
fromUnixTime,
|
||||
formatDistanceToNow,
|
||||
differenceInDays,
|
||||
@@ -33,6 +36,22 @@ export const messageTimestamp = (time, dateFormat = 'MMM d, yyyy') => {
|
||||
return messageDate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp relative to today: the time for today, a caller-
|
||||
* supplied label for yesterday, and a date otherwise. The yesterday label is
|
||||
* passed in so the caller keeps ownership of translation.
|
||||
* @param {number} time - Unix timestamp.
|
||||
* @param {string} yesterdayLabel - Localized label shown for yesterday.
|
||||
* @returns {string} Formatted timestamp string.
|
||||
*/
|
||||
export const relativeDayTimestamp = (time, yesterdayLabel) => {
|
||||
const date = fromUnixTime(time);
|
||||
if (isToday(date)) return format(date, 'h:mm a');
|
||||
if (isYesterday(date)) return yesterdayLabel;
|
||||
if (isThisYear(date)) return format(date, 'MMM d');
|
||||
return format(date, 'MMM d, yyyy');
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Unix timestamp to a relative time string (e.g., 3 hours ago).
|
||||
* @param {number} time - Unix timestamp.
|
||||
|
||||
@@ -11,6 +11,8 @@ import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
const TRANSCRIPT_COOLDOWN_MS = 15000;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
@@ -24,6 +26,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
isSendingTranscript: false,
|
||||
transcriptCooldown: false,
|
||||
transcriptCooldownTimer: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -57,6 +62,9 @@ export default {
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
@@ -90,19 +98,35 @@ export default {
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
startTranscriptCooldown() {
|
||||
this.transcriptCooldown = true;
|
||||
clearTimeout(this.transcriptCooldownTimer);
|
||||
this.transcriptCooldownTimer = setTimeout(() => {
|
||||
this.transcriptCooldown = false;
|
||||
}, TRANSCRIPT_COOLDOWN_MS);
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!this.hasEmail ||
|
||||
this.isSendingTranscript ||
|
||||
this.transcriptCooldown
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.isSendingTranscript = true;
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
this.startTranscriptCooldown();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
} finally {
|
||||
this.isSendingTranscript = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -144,6 +168,7 @@ export default {
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
:disabled="isSendingTranscript || transcriptCooldown"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
|
||||
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
end
|
||||
|
||||
def get_channel_from_wb_payload(wb_params)
|
||||
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
# validate to ensure the phone number id matches the whatsapp channel
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user