Compare commits

..
199 changed files with 5815 additions and 4473 deletions
-1
View File
@@ -94,4 +94,3 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
CLAUDE.local.md
+1 -2
View File
@@ -179,8 +179,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.4.3'
gem 'ruby_llm-schema'
gem 'ai-agents', '>= 0.2.1'
gem 'shopify_api'
+3 -5
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.4.3)
ai-agents (0.2.1)
ruby_llm (~> 1.3)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
@@ -720,7 +720,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.5.1)
ruby_llm (1.3.1)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -729,7 +729,6 @@ GEM
faraday-retry (>= 1)
marcel (~> 1.0)
zeitwerk (~> 2)
ruby_llm-schema (0.1.0)
ruby_parser (3.20.0)
sexp_processor (~> 4.16)
sass (3.7.4)
@@ -911,7 +910,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.4.3)
ai-agents (>= 0.2.1)
annotate
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1005,7 +1004,6 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
ruby-openai
ruby_llm-schema
scout_apm
scss_lint
seed_dump
+1 -1
View File
@@ -1 +1 @@
3.4.2
3.4.0
@@ -0,0 +1,176 @@
# frozen_string_literal: true
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::BaseController
before_action :ensure_enterprise_account
before_action :fetch_agent_capacity_policy, only: [:show, :update, :destroy, :set_inbox_limit, :remove_inbox_limit, :assign_user, :remove_user]
before_action :check_authorization
def index
@agent_capacity_policies = Enterprise::AgentCapacityPolicy.where(account_id: Current.account.id).includes(:users, :inbox_capacity_limits)
render json: { agent_capacity_policies: serialize_agent_capacity_policies(@agent_capacity_policies) }
end
def show
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
end
def create
@agent_capacity_policy = Enterprise::AgentCapacityPolicy.new(agent_capacity_policy_params)
@agent_capacity_policy.account_id = Current.account.id
if @agent_capacity_policy.save
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }, status: :created
else
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @agent_capacity_policy.update(agent_capacity_policy_params)
render json: { agent_capacity_policy: serialize_agent_capacity_policy(@agent_capacity_policy) }
else
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
if @agent_capacity_policy.destroy
head :ok
else
render json: { errors: @agent_capacity_policy.errors.full_messages }, status: :unprocessable_entity
end
end
# Set inbox capacity limit for a policy
def set_inbox_limit
inbox = Current.account.inboxes.find(params[:inbox_id])
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_or_initialize_by(inbox: inbox)
if inbox_limit.update(conversation_limit: params[:conversation_limit])
render json: {
inbox_capacity_limit: serialize_inbox_capacity_limit(inbox_limit)
}
else
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
end
end
# Remove inbox capacity limit from a policy
def remove_inbox_limit
inbox = Current.account.inboxes.find(params[:inbox_id])
inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find_by(inbox: inbox)
if inbox_limit
if inbox_limit.destroy
head :ok
else
render json: { errors: inbox_limit.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'Inbox limit not found' }, status: :not_found
end
end
# Assign a user to a capacity policy
def assign_user
user = Current.account.users.find(params[:user_id])
account_user = Current.account.account_users.find_by!(user: user)
# Update the account_user to assign to this policy
if account_user.update(agent_capacity_policy_id: @agent_capacity_policy.id)
render json: {
message: 'User assigned successfully',
user_id: user.id,
agent_capacity_policy_id: @agent_capacity_policy.id
}
else
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
end
end
# Remove a user from a capacity policy
def remove_user
user = Current.account.users.find(params[:user_id])
account_user = Current.account.account_users.find_by!(user: user)
if account_user.agent_capacity_policy_id == @agent_capacity_policy.id
if account_user.update(agent_capacity_policy_id: nil)
head :ok
else
render json: { errors: account_user.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'User not assigned to this policy' }, status: :not_found
end
end
# Get current capacity status for an agent
def agent_capacity
user = Current.account.users.find(params[:agent_id])
inbox = params[:inbox_id] ? Current.account.inboxes.find(params[:inbox_id]) : nil
capacity_service = Enterprise::AssignmentV2::CapacityService.new
capacity_data = if inbox
capacity_service.get_agent_capacity(user, inbox)
else
capacity_service.get_agent_overall_capacity(user)
end
render json: { agent_capacity: capacity_data }
end
private
def ensure_enterprise_account
return if Current.account.feature_enabled?(:enterprise_agent_capacity)
render json: {
error: 'Agent capacity policies are only available for enterprise accounts'
}, status: :forbidden
end
def fetch_agent_capacity_policy
@agent_capacity_policy = Enterprise::AgentCapacityPolicy
.where(account_id: Current.account.id)
.includes(:users, inbox_capacity_limits: :inbox)
.find(params[:id])
end
def agent_capacity_policy_params
params.require(:agent_capacity_policy).permit(:name, :description, exclusion_rules: {})
end
def check_authorization
authorize(Enterprise::AgentCapacityPolicy) if defined?(Enterprise::AgentCapacityPolicy)
end
def serialize_agent_capacity_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
exclusion_rules: policy.exclusion_rules,
user_count: policy.users.count,
inbox_limit_count: policy.inbox_capacity_limits.count,
inbox_limits: policy.inbox_capacity_limits.map { |limit| serialize_inbox_capacity_limit(limit) },
users: policy.users.map { |user| { id: user.id, name: user.name, email: user.email, avatar_url: user.avatar_url } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_agent_capacity_policies(policies)
policies.map { |policy| serialize_agent_capacity_policy(policy) }
end
def serialize_inbox_capacity_limit(limit)
{
id: limit.id,
inbox_id: limit.inbox_id,
inbox_name: limit.inbox.name,
conversation_limit: limit.conversation_limit,
created_at: limit.created_at,
updated_at: limit.updated_at
}
end
end
@@ -0,0 +1,79 @@
# frozen_string_literal: true
class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_assignment_policy, only: [:show, :update, :destroy]
before_action :check_authorization
def index
@assignment_policies = Current.account.assignment_policies.includes(:inboxes)
render json: { assignment_policies: serialize_assignment_policies(@assignment_policies) }
end
def show
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
end
def create
@assignment_policy = Current.account.assignment_policies.build(assignment_policy_params)
if @assignment_policy.save
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }, status: :created
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @assignment_policy.update(assignment_policy_params)
render json: { assignment_policy: serialize_assignment_policy(@assignment_policy) }
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
if @assignment_policy.destroy
head :ok
else
render json: { errors: @assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
private
def fetch_assignment_policy
@assignment_policy = Current.account.assignment_policies.find(params[:id])
end
def assignment_policy_params
params.require(:assignment_policy).permit(
:name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled
)
end
def serialize_assignment_policy(policy)
{
id: policy.id,
name: policy.name,
description: policy.description,
assignment_order: policy.assignment_order,
conversation_priority: policy.conversation_priority,
fair_distribution_limit: policy.fair_distribution_limit,
fair_distribution_window: policy.fair_distribution_window,
enabled: policy.enabled,
inbox_count: policy.inboxes.count,
inboxes: policy.inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
created_at: policy.created_at,
updated_at: policy.updated_at
}
end
def serialize_assignment_policies(policies)
policies.map { |policy| serialize_assignment_policy(policy) }
end
def check_authorization
authorize(AssignmentPolicy)
end
end
@@ -0,0 +1,81 @@
# frozen_string_literal: true
class Api::V1::Accounts::InboxAssignmentPoliciesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :check_authorization
def show
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}
else
render json: {
inbox_assignment_policy: nil,
message: 'No assignment policy assigned to this inbox'
}
end
end
def create
# Remove existing assignment if any
@inbox.inbox_assignment_policy&.destroy
@assignment_policy = Current.account.assignment_policies.find(params[:assignment_policy_id])
@inbox_assignment_policy = @inbox.build_inbox_assignment_policy(assignment_policy: @assignment_policy)
if @inbox_assignment_policy.save
render json: {
inbox_assignment_policy: serialize_inbox_assignment_policy(@inbox_assignment_policy)
}, status: :created
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@inbox_assignment_policy = @inbox.inbox_assignment_policy
if @inbox_assignment_policy
if @inbox_assignment_policy.destroy
head :ok
else
render json: { errors: @inbox_assignment_policy.errors.full_messages }, status: :unprocessable_entity
end
else
render json: { error: 'No assignment policy found for this inbox' }, status: :not_found
end
end
private
def fetch_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id])
end
def check_authorization
authorize(@inbox, :update?)
end
def serialize_inbox_assignment_policy(inbox_assignment_policy)
{
id: inbox_assignment_policy.id,
inbox_id: inbox_assignment_policy.inbox_id,
assignment_policy_id: inbox_assignment_policy.assignment_policy_id,
assignment_policy: {
id: inbox_assignment_policy.assignment_policy.id,
name: inbox_assignment_policy.assignment_policy.name,
description: inbox_assignment_policy.assignment_policy.description,
assignment_order: inbox_assignment_policy.assignment_policy.assignment_order,
conversation_priority: inbox_assignment_policy.assignment_policy.conversation_priority,
fair_distribution_limit: inbox_assignment_policy.assignment_policy.fair_distribution_limit,
fair_distribution_window: inbox_assignment_policy.assignment_policy.fair_distribution_window,
enabled: inbox_assignment_policy.assignment_policy.enabled
},
created_at: inbox_assignment_policy.created_at,
updated_at: inbox_assignment_policy.updated_at
}
end
end
@@ -0,0 +1,148 @@
# frozen_string_literal: true
class Api::V1::Accounts::LeavesController < Api::V1::Accounts::BaseController
before_action :fetch_leave, only: [:show, :update, :destroy, :approve, :reject]
before_action -> { check_authorization(Leave) }, only: [:index, :create]
before_action :authorize_leave, only: [:show, :update, :destroy]
before_action :authorize_approval, only: [:approve, :reject]
def index
@leaves = leave_service.list(filter_params)
render json: { leaves: serialize_leaves(@leaves) }
end
def show
render json: { leave: serialize_leave(@leave) }
end
def create
account_user = find_or_authorize_account_user
service = Leaves::LeaveService.new(
account: Current.account,
account_user: account_user,
current_user: Current.user
)
result = service.create(leave_params)
if result[:success]
render json: { leave: serialize_leave(result[:leave]) }, status: :created
else
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
def update
result = leave_service.update(@leave, leave_params)
if result[:success]
render json: { leave: serialize_leave(result[:leave]) }
else
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
def destroy
if @leave.destroy
head :ok
else
render json: { errors: @leave.errors.full_messages }, status: :unprocessable_entity
end
end
def approve
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
result = service.approve(params[:comments])
if result[:success]
render json: { leave: serialize_leave(result[:leave]) }
else
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
def reject
service = Leaves::LeaveApprovalService.new(leave: @leave, approver: Current.user)
result = service.reject(params[:reason])
if result[:success]
render json: { leave: serialize_leave(result[:leave]) }
else
render json: { errors: result[:errors] }, status: :unprocessable_entity
end
end
private
def fetch_leave
@leave = Current.account.leaves.find(params[:id])
end
def authorize_leave
authorize @leave
end
def authorize_approval
authorize @leave, :approve?
end
def find_or_authorize_account_user
if params[:user_id].present? && Current.account_user.administrator?
user = Current.account.users.find(params[:user_id])
Current.account.account_users.find_by!(user: user)
else
Current.account.account_users.find_by!(user: Current.user)
end
end
def leave_service
@leave_service ||= if @leave
Leaves::LeaveService.new(
account: Current.account,
account_user: @leave.account_user,
current_user: Current.user
)
else
# For index action, we don't have a specific leave
Leaves::LeaveService.new(
account: Current.account,
account_user: nil,
current_user: Current.user
)
end
end
def leave_params
params.require(:leave).permit(:start_date, :end_date, :leave_type, :reason)
end
def filter_params
params.permit(:status, :leave_type, :start_date, :end_date, :user_id)
end
def serialize_leave(leave)
{
id: leave.id,
start_date: leave.start_date,
end_date: leave.end_date,
leave_type: leave.leave_type,
status: leave.status,
reason: leave.reason,
days_count: leave.days_count,
approved_by: leave.approved_by&.name,
approved_at: leave.approved_at,
user: {
id: leave.user.id,
name: leave.user.name,
email: leave.user.email,
avatar_url: leave.user.avatar_url
},
created_at: leave.created_at,
updated_at: leave.updated_at
}
end
def serialize_leaves(leaves)
leaves.map { |leave| serialize_leave(leave) }
end
end
@@ -47,20 +47,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
head :ok
end
def send_instructions
email = permitted_params[:email]
return render_could_not_create_error(I18n.t('portals.send_instructions.email_required')) if email.blank?
return render_could_not_create_error(I18n.t('portals.send_instructions.invalid_email_format')) unless valid_email?(email)
return render_could_not_create_error(I18n.t('portals.send_instructions.custom_domain_not_configured')) if @portal.custom_domain.blank?
PortalInstructionsMailer.send_cname_instructions(
portal: @portal,
recipient_email: email
).deliver_later
render json: { message: I18n.t('portals.send_instructions.instructions_sent_successfully') }, status: :ok
end
def process_attached_logo
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_by(id: blob_id)
@@ -74,12 +60,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:id, :email)
params.permit(:id)
end
def portal_params
params.require(:portal).permit(
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
:account_id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
)
end
@@ -102,10 +88,4 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
domain = URI.parse(@portal.custom_domain)
domain.is_a?(URI::HTTP) ? domain.host : @portal.custom_domain
end
def valid_email?(email)
ValidEmail2::Address.new(email).valid?
end
end
Api::V1::Accounts::PortalsController.prepend_mod_with('Api::V1::Accounts::PortalsController')
+1 -1
View File
@@ -136,7 +136,7 @@ export default {
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTL }"
:dir="isRTL ? 'rtl' : 'ltr'"
>
@@ -1,36 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainScenarios extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
}
get({ assistantId, page = 1, searchKey } = {}) {
return axios.get(`${this.url}/${assistantId}/scenarios`, {
params: { page, searchKey },
});
}
show({ assistantId, id }) {
return axios.get(`${this.url}/${assistantId}/scenarios/${id}`);
}
create({ assistantId, ...data } = {}) {
return axios.post(`${this.url}/${assistantId}/scenarios`, {
scenario: data,
});
}
update({ assistantId, id }, data = {}) {
return axios.put(`${this.url}/${assistantId}/scenarios/${id}`, {
scenario: data,
});
}
delete({ assistantId, id }) {
return axios.delete(`${this.url}/${assistantId}/scenarios/${id}`);
}
}
export default new CaptainScenarios();
@@ -1,16 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainTools extends ApiClient {
constructor() {
super('captain/assistants/tools', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, {
params,
});
}
}
export default new CaptainTools();
@@ -21,14 +21,6 @@ class PortalsAPI extends ApiClient {
deleteLogo(portalSlug) {
return axios.delete(`${this.url}/${portalSlug}/logo`);
}
sendCnameInstructions(portalSlug, email) {
return axios.post(`${this.url}/${portalSlug}/send_instructions`, { email });
}
sslStatus(portalSlug) {
return axios.get(`${this.url}/${portalSlug}/ssl_status`);
}
}
export default PortalsAPI;
@@ -37,6 +37,30 @@ body {
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
.tooltip {
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
}
@@ -20,7 +20,6 @@ const props = defineProps({
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
@@ -99,7 +98,6 @@ watch(
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -1,9 +1,6 @@
<script setup>
import { ref, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { helpers } from '@vuelidate/validators';
import { isValidDomain } from '@chatwoot/utils';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -29,20 +26,6 @@ const formState = reactive({
customDomain: props.customDomain,
});
const rules = {
customDomain: {
isValidDomain: helpers.withMessage(
() =>
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.FORMAT_ERROR'
),
isValidDomain
),
},
};
const v$ = useVuelidate(rules, formState);
watch(
() => props.customDomain,
newVal => {
@@ -50,10 +33,7 @@ watch(
}
);
const handleDialogConfirm = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
const handleDialogConfirm = () => {
emit('addCustomDomain', formState.customDomain);
};
@@ -87,11 +67,6 @@ defineExpose({ dialogRef });
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DIALOG.PLACEHOLDER'
)
"
:message="
v$.customDomain.$error ? v$.customDomain.$errors[0].$message : ''
"
:message-type="v$.customDomain.$error ? 'error' : 'info'"
@blur="v$.customDomain.$touch()"
/>
</Dialog>
</template>
@@ -1,15 +1,9 @@
<script setup>
import { reactive, computed, ref } from 'vue';
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { getHostNameFromURL } from 'dashboard/helper/URLHelper';
import { email, required } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
customDomain: {
@@ -18,20 +12,10 @@ const props = defineProps({
},
});
const emit = defineEmits(['send', 'close']);
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const state = reactive({
email: '',
});
const validationRules = {
email: { email, required },
};
const v$ = useVuelidate(validationRules, state);
const domain = computed(() => {
const { hostURL, helpCenterURL } = window?.chatwootConfig || {};
return getHostNameFromURL(helpCenterURL) || getHostNameFromURL(hostURL) || '';
@@ -41,34 +25,10 @@ const subdomainCNAME = computed(
() => `${props.customDomain} CNAME ${domain.value}`
);
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(subdomainCNAME.value);
useAlert(
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.COPY'
)
);
};
const dialogRef = ref(null);
const resetForm = () => {
v$.value.$reset();
state.email = '';
};
const onClose = () => {
resetForm();
emit('close');
};
const handleSend = async () => {
const isFormCorrect = await v$.value.$validate();
if (!isFormCorrect) return;
emit('send', state.email);
onClose();
const handleDialogConfirm = () => {
emit('confirm');
};
defineExpose({ dialogRef });
@@ -77,103 +37,42 @@ defineExpose({ dialogRef });
<template>
<Dialog
ref="dialogRef"
:title="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
"
:confirm-button-label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.CONFIRM_BUTTON_LABEL'
)
"
:show-cancel-button="false"
:show-confirm-button="false"
@close="resetForm"
@confirm="handleDialogConfirm"
>
<NextButton
icon="i-lucide-x"
sm
ghost
slate
class="flex-shrink-0 absolute top-2 ltr:right-2 rtl:left-2"
@click="onClose"
/>
<div class="flex flex-col gap-6 divide-y divide-n-strong">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</div>
<div class="flex items-center gap-3 w-full">
<span
class="min-h-10 px-3 py-2.5 inline-flex items-center w-full text-sm bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<NextButton
faded
slate
type="button"
icon="i-lucide-copy"
class="flex-shrink-0"
@click="handleCopy"
/>
</div>
</div>
<template #description>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.DESCRIPTION'
)
}}
</p>
</template>
<div class="flex flex-col gap-6 pt-6">
<div class="flex flex-col gap-2 ltr:pr-10 rtl:pl-10">
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.HEADER'
)
}}
</h3>
<p class="mb-0 text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.DESCRIPTION'
)
}}
</p>
</div>
<form
class="flex items-start gap-3 w-full"
@submit.prevent="handleSend"
>
<Input
v-model="state.email"
:placeholder="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.PLACEHOLDER'
)
"
:message="
v$.email.$error
? t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.ERROR'
)
: ''
"
:message-type="v$.email.$error ? 'error' : 'info'"
class="w-full"
@blur="v$.email.$touch()"
/>
<NextButton
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.SEND_INSTRUCTIONS.SEND_BUTTON'
)
"
type="submit"
class="flex-shrink-0"
/>
</form>
</div>
<div class="flex flex-col gap-6">
<span
class="h-10 px-3 py-2.5 text-sm select-none bg-transparent border rounded-lg text-n-slate-11 border-n-strong"
>
{{ subdomainCNAME }}
</span>
<p class="text-sm text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.DNS_CONFIGURATION_DIALOG.HELP_TEXT'
)
}}
</p>
</div>
</Dialog>
</template>
@@ -1,7 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import AddCustomDomainDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/AddCustomDomainDialog.vue';
import DNSConfigurationDialog from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/DNSConfigurationDialog.vue';
@@ -12,52 +11,11 @@ const props = defineProps({
type: Object,
required: true,
},
isFetchingStatus: {
type: Boolean,
required: true,
},
});
const emit = defineEmits([
'updatePortalConfiguration',
'refreshStatus',
'sendCnameInstructions',
]);
const SSL_STATUS = {
LIVE: ['active', 'staging_active'],
PENDING: [
'provisioned',
'pending',
'initializing',
'pending_validation',
'pending_deployment',
'pending_issuance',
'holding_deployment',
'holding_validation',
'pending_expiration',
'pending_cleanup',
'pending_deletion',
'staging_deployment',
'backup_issued',
],
ERROR: [
'blocked',
'inactive',
'moved',
'expired',
'deleted',
'timed_out_initializing',
'timed_out_validation',
'timed_out_issuance',
'timed_out_deployment',
'timed_out_deletion',
'deactivating',
],
};
const emit = defineEmits(['updatePortalConfiguration']);
const { t } = useI18n();
const { isOnChatwootCloud } = useAccount();
const addCustomDomainDialogRef = ref(null);
const dnsConfigurationDialogRef = ref(null);
@@ -67,45 +25,6 @@ const customDomainAddress = computed(
() => props.activePortal?.custom_domain || ''
);
const sslSettings = computed(() => props.activePortal?.ssl_settings || {});
const verificationErrors = computed(
() => sslSettings.value.verification_errors || ''
);
const isLive = computed(() =>
SSL_STATUS.LIVE.includes(sslSettings.value.status)
);
const isPending = computed(() =>
SSL_STATUS.PENDING.includes(sslSettings.value.status)
);
const isError = computed(() =>
SSL_STATUS.ERROR.includes(sslSettings.value.status)
);
const statusText = computed(() => {
if (isLive.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.LIVE'
);
if (isPending.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.PENDING'
);
if (isError.value)
return t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS.ERROR'
);
return '';
});
const statusColors = computed(() => {
if (isLive.value)
return { text: 'text-n-teal-11', bubble: 'outline-n-teal-6 bg-n-teal-9' };
if (isError.value)
return { text: 'text-n-ruby-11', bubble: 'outline-n-ruby-6 bg-n-ruby-9' };
return { text: 'text-n-amber-11', bubble: 'outline-n-amber-6 bg-n-amber-9' };
});
const updatePortalConfiguration = customDomain => {
const portal = {
id: props.activePortal?.id,
@@ -123,17 +42,6 @@ const closeDNSConfigurationDialog = () => {
updatedDomainAddress.value = '';
dnsConfigurationDialogRef.value.dialogRef.close();
};
const onClickRefreshSSLStatus = () => {
emit('refreshStatus');
};
const onClickSend = email => {
emit('sendCnameInstructions', {
portalSlug: props.activePortal?.slug,
email,
});
};
</script>
<template>
@@ -155,76 +63,33 @@ const onClickSend = email => {
</span>
</div>
<div class="flex flex-col w-full gap-4">
<div class="flex items-center justify-between w-full gap-2">
<div v-if="customDomainAddress" class="flex flex-col gap-1">
<div class="flex items-center w-full h-8 gap-4">
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<span
v-if="!isLive && isOnChatwootCloud"
class="text-sm text-n-slate-11"
>
<div class="flex justify-between w-full gap-2">
<div
v-if="customDomainAddress"
class="flex items-center w-full h-8 gap-4"
>
<label class="text-sm font-medium text-n-slate-12">
{{
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
)
}}
</label>
<span class="text-sm text-n-slate-12">
{{ customDomainAddress }}
</span>
</div>
<div class="flex items-center">
<div v-if="customDomainAddress" class="flex items-center gap-3">
<div
v-if="statusText && isOnChatwootCloud"
v-tooltip="verificationErrors"
class="flex items-center gap-3 flex-shrink-0"
>
<span
class="size-1.5 rounded-full outline outline-2 block flex-shrink-0"
:class="statusColors.bubble"
/>
<span
:class="statusColors.text"
class="text-sm leading-[16px] font-medium"
>
{{ statusText }}
</span>
</div>
<div
v-if="statusText && isOnChatwootCloud"
class="w-px h-3 bg-n-weak"
/>
<Button
slate
sm
link
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
class="hover:!no-underline flex-shrink-0"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<div v-if="isOnChatwootCloud" class="w-px h-3 bg-n-weak" />
<Button
v-if="isOnChatwootCloud"
slate
sm
link
icon="i-lucide-refresh-ccw"
:class="isFetchingStatus && 'animate-spin'"
@click="onClickRefreshSSLStatus"
/>
</div>
<div class="flex items-center justify-end w-full">
<Button
v-if="customDomainAddress"
color="slate"
:label="
t(
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.EDIT_BUTTON'
)
"
@click="addCustomDomainDialogRef.dialogRef.open()"
/>
<Button
v-else
:label="
@@ -247,8 +112,7 @@ const onClickSend = email => {
<DNSConfigurationDialog
ref="dnsConfigurationDialogRef"
:custom-domain="updatedDomainAddress || customDomainAddress"
@close="closeDNSConfigurationDialog"
@send="onClickSend"
@confirm="closeDNSConfigurationDialog"
/>
</div>
</template>
@@ -26,8 +26,6 @@ const emit = defineEmits([
'updatePortal',
'updatePortalConfiguration',
'deletePortal',
'refreshStatus',
'sendCnameInstructions',
]);
const { t } = useI18n();
@@ -38,7 +36,6 @@ const confirmDeletePortalDialogRef = ref(null);
const currentPortalSlug = computed(() => route.params.portalSlug);
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isFetchingSSLStatus = useMapGetter('portals/isFetchingSSLStatus');
const activePortal = computed(() => {
return props.portals?.find(portal => portal.slug === currentPortalSlug.value);
@@ -56,14 +53,6 @@ const handleUpdatePortalConfiguration = portal => {
emit('updatePortalConfiguration', portal);
};
const fetchSSLStatus = () => {
emit('refreshStatus');
};
const handleSendCnameInstructions = payload => {
emit('sendCnameInstructions', payload);
};
const openConfirmDeletePortalDialog = () => {
confirmDeletePortalDialogRef.value.dialogRef.open();
};
@@ -96,10 +85,7 @@ const handleDeletePortal = () => {
<PortalConfigurationSettings
:active-portal="activePortal"
:is-fetching="isFetching"
:is-fetching-status="isFetchingSSLStatus"
@update-portal-configuration="handleUpdatePortalConfiguration"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
<div class="w-full h-px bg-n-weak" />
<div class="flex items-end justify-between w-full gap-4">
@@ -63,12 +63,11 @@ const lastActivityAt = computed(() => {
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
icon: isUnread.value ? 'mail' : 'mail-unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
]);
const messageClasses = computed(() => ({
@@ -154,7 +153,7 @@ onBeforeMount(contextMenuActions.close);
<template>
<div
role="button"
class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer"
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
@contextmenu="contextMenuActions.open($event)"
@click="emit('click')"
>
@@ -233,7 +232,7 @@ onBeforeMount(contextMenuActions.close);
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-xs text-n-slate-10">
<span class="text-sm text-n-slate-10">
{{ lastActivityAt }}
</span>
</div>
@@ -1,6 +1,5 @@
<script setup>
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
@@ -45,10 +44,7 @@ const onClickCancel = () => {
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<div class="inline-flex relative">
<Button
:label="buttonLabel"
sm
@@ -1,155 +0,0 @@
<script setup>
import { computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { vOnClickOutside } from '@vueuse/components';
import { required, minLength } from '@vuelidate/validators';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const emit = defineEmits(['add']);
const { t } = useI18n();
const [showPopover, togglePopover] = useToggle();
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const resetState = () => {
Object.assign(state, {
id: '',
title: '',
description: '',
instruction: '',
});
};
const onClickAdd = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
await emit('add', state);
resetState();
togglePopover(false);
};
const onClickCancel = () => {
togglePopover(false);
};
</script>
<template>
<div
v-on-click-outside="() => togglePopover(false)"
class="inline-flex relative"
>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.CREATE')"
sm
slate
class="flex-shrink-0"
@click="togglePopover(!showPopover)"
/>
<div
v-if="showPopover"
class="w-[31.25rem] absolute top-10 ltr:left-0 rtl:right-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6 z-50"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.TITLE`) }}
</h3>
<div class="flex flex-col gap-4">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER'
)
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER'
)
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
</div>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CREATE')"
class="w-full"
@click="onClickAdd"
/>
</div>
</div>
</div>
</template>
@@ -73,6 +73,7 @@ const saveEdit = () => {
v-if="isEditing"
v-model="editedContent"
focus-on-mount
custom-input-class="flex items-center gap-2 text-sm text-n-slate-12"
@keyup.enter="saveEdit"
/>
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
@@ -1,45 +0,0 @@
<script setup>
import ScenariosCard from './ScenariosCard.vue';
const sampleScenarios = [
{
id: 1,
title: 'Refund Order',
description: 'User requests a refund for a recent purchase.',
instruction:
'Gather order details and reason for refund. Use [Order Search](tool://order_search) then submit with [Refund Payment](tool://refund_payment).',
tools: ['order_search', 'refund_payment'],
},
{
id: 2,
title: 'Bug Report',
description: 'Customer reports a bug in the mobile app.',
instruction:
'Ask for reproduction steps and environment. Check [Known Issues](tool://known_issues) then create ticket with [Create Bug Report](tool://bug_report_create).',
tools: ['known_issues', 'bug_report_create'],
},
];
</script>
<template>
<Story
title="Captain/Assistant/ScenariosCard"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="Default">
<div
v-for="scenario in sampleScenarios"
:key="scenario.id"
class="px-4 py-4 bg-n-background"
>
<ScenariosCard
:id="scenario.id"
:title="scenario.title"
:description="scenario.description"
:instruction="scenario.instruction"
:tools="scenario.tools"
/>
</div>
</Variant>
</Story>
</template>
@@ -1,218 +0,0 @@
<script setup>
import { computed, h, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
instruction: {
type: String,
required: true,
},
tools: {
type: Array,
required: true,
},
selectable: {
type: Boolean,
default: false,
},
isSelected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'hover', 'delete', 'update']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const state = reactive({
id: '',
title: '',
description: '',
instruction: '',
});
const [isEditing, toggleEditing] = useToggle();
const startEdit = () => {
Object.assign(state, {
id: props.id,
title: props.title,
description: props.description,
instruction: props.instruction,
tools: props.tools,
});
toggleEditing(true);
};
const rules = {
title: { required, minLength: minLength(1) },
description: { required },
instruction: { required },
};
const v$ = useVuelidate(rules, state);
const titleError = computed(() =>
v$.value.title.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.ERROR')
: ''
);
const descriptionError = computed(() =>
v$.value.description.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.ERROR')
: ''
);
const onClickUpdate = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
emit('update', { ...state });
toggleEditing(false);
};
const instructionError = computed(() =>
v$.value.instruction.$error
? t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.ERROR')
: ''
);
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('p', {
class: `text-sm text-n-slate-12 py-4 mb-0 [&_ol]:list-decimal ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
</script>
<template>
<CardLayout
selectable
class="relative [&>div]:!py-4"
:class="{
'[&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4': !isEditing,
'[&>div]:ltr:!pr-10 [&>div]:rtl:!pl-10': isEditing,
}"
layout="row"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="selectable && !isEditing"
class="absolute top-[1.125rem] ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div v-if="!isEditing" class="flex flex-col w-full">
<div class="flex items-start justify-between w-full gap-2">
<div class="flex flex-col items-start">
<span class="text-sm text-n-slate-12 font-medium">{{ title }}</span>
<span class="text-sm text-n-slate-11 mt-2">
{{ description }}
</span>
</div>
<div class="flex items-center gap-2">
<!-- <Button label="Test" slate xs ghost class="!text-sm" />
<span class="w-px h-4 bg-n-weak" /> -->
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
<span class="w-px h-4 bg-n-weak" />
<Button
icon="i-lucide-trash"
slate
xs
ghost
@click="emit('delete', id)"
/>
</div>
</div>
<component :is="renderInstruction(formatMessage(instruction, false))" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
<div v-else class="overflow-hidden flex flex-col gap-4 w-full">
<Input
v-model="state.title"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.TITLE.PLACEHOLDER')
"
:message="titleError"
:message-type="titleError ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.LABEL')
"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.DESCRIPTION.PLACEHOLDER')
"
:message="descriptionError"
:message-type="descriptionError ? 'error' : 'info'"
show-character-count
/>
<Editor
v-model="state.instruction"
:label="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.LABEL')
"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.INSTRUCTION.PLACEHOLDER')
"
:message="instructionError"
:message-type="instructionError ? 'error' : 'info'"
:show-character-count="false"
enable-captain-tools
/>
<div class="flex items-center gap-3">
<Button
faded
slate
sm
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.CANCEL')"
@click="toggleEditing(false)"
/>
<Button
sm
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.UPDATE.UPDATE')"
@click="onClickUpdate"
/>
</div>
</div>
</CardLayout>
</template>
@@ -1,37 +0,0 @@
<script setup>
import { ref } from 'vue';
import ToolsDropdown from './ToolsDropdown.vue';
const items = [
{
id: 'order_search',
title: 'Order Search',
description: 'Lookup orders by customer ID, email, or order number',
},
{
id: 'refund_payment',
title: 'Refund Payment',
description: 'Initiates a refund on a specific payment',
},
{
id: 'fetch_customer',
title: 'Fetch Customer',
description: 'Pulls customer details (email, tags, last seen, etc.)',
},
];
const selectedIndex = ref(0);
</script>
<template>
<Story
title="Captain/Assistant/ToolsDropdown"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default">
<div class="relative h-80 bg-n-background p-4">
<ToolsDropdown :items="items" :selected-index="selectedIndex" />
</div>
</Variant>
</Story>
</template>
@@ -1,54 +0,0 @@
<script setup>
import { ref, watch, nextTick } from 'vue';
const props = defineProps({
items: {
type: Array,
required: true,
},
selectedIndex: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const toolsDropdownRef = ref(null);
const onItemClick = idx => emit('select', idx);
watch(
() => props.selectedIndex,
() => {
nextTick(() => {
const el = toolsDropdownRef.value?.querySelector(
`#tool-item-${props.selectedIndex}`
);
if (el) {
el.scrollIntoView({ block: 'nearest', behavior: 'auto' });
}
});
},
{ immediate: true }
);
</script>
<template>
<div
ref="toolsDropdownRef"
class="w-[22.5rem] p-2 flex flex-col gap-1 z-50 absolute rounded-xl bg-n-alpha-3 shadow outline outline-1 outline-n-weak backdrop-blur-[50px] max-h-[20rem] overflow-y-auto"
>
<div
v-for="(tool, idx) in items"
:id="`tool-item-${idx}`"
:key="tool.id || idx"
:class="{ 'bg-n-alpha-black2': idx === selectedIndex }"
class="flex flex-col gap-1 rounded-md py-2 px-2 cursor-pointer hover:bg-n-alpha-black2"
@click="onItemClick(idx)"
>
<span class="text-n-slate-12 font-medium text-sm">{{ tool.title }}</span>
<span class="text-n-slate-11 text-sm">{{ tool.description }}</span>
</div>
</div>
</template>
@@ -2,7 +2,6 @@
import { computed, defineModel, h, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import FilterSelect from './inputs/FilterSelect.vue';
import MultiSelect from './inputs/MultiSelect.vue';
import SingleSelect from './inputs/SingleSelect.vue';
@@ -179,11 +178,11 @@ defineExpose({ validate });
disable-search
:options="booleanOptions"
/>
<Input
<input
v-else
v-model="values"
:type="inputType === 'date' ? 'date' : 'text'"
class="[&>input]:h-8 [&>input]:py-1.5 [&>input]:outline-offset-0"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</template>
@@ -9,7 +9,6 @@ import { useContactFilterContext } from './contactProvider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
@@ -110,13 +109,16 @@ const outsideClickHandler = [
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isSegmentView">
<div class="pb-6 border-b border-n-weak">
<Input
<label class="pb-6 border-b border-n-weak">
<div class="mb-2 text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL') }}
</div>
<input
v-model="segmentNameLocal"
:label="$t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL')"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
/>
</div>
</label>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
@@ -9,7 +9,6 @@ import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ConditionRow from './ConditionRow.vue';
const props = defineProps({
@@ -111,13 +110,16 @@ const outsideClickHandler = [
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<div class="border-b border-n-weak pb-6">
<Input
<label class="border-b border-n-weak pb-6">
<div class="text-n-slate-11 text-sm mb-2">
{{ t('FILTER.FOLDER_LABEL') }}
</div>
<input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
</label>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
@@ -6,12 +6,10 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { vOnClickOutside } from '@vueuse/components';
import { useTrack } from 'dashboard/composables';
import NextButton from 'next/button/Button.vue';
import NextInput from 'dashboard/components-next/input/Input.vue';
export default {
components: {
NextButton,
NextInput,
},
directives: {
onClickOutside: vOnClickOutside,
@@ -105,13 +103,20 @@ export default {
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
</h3>
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
<NextInput
v-model="name"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
@blur="v$.name.$touch"
/>
<label :class="{ error: v$.name.$error }">
<input
v-model="name"
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span
v-if="v$.name.$error"
class="text-xs text-n-ruby-11 ml-1 rtl:mr-1"
>
{{ $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') }}
</span>
</label>
<div class="flex flex-row justify-end w-full gap-2">
<NextButton faded slate sm @click.prevent="onClose">
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
@@ -1,58 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isMobileSidebarOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle']);
const route = useRoute();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const toggleSidebar = () => {
emit('toggle');
};
</script>
<template>
<div
v-if="!isConversationRoute"
id="mobile-sidebar-launcher"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
:class="[
{
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
},
]"
>
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-lucide-menu"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -8,7 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
@@ -18,18 +17,10 @@ import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
isMobileSidebarOpen: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
'closeMobileSidebar',
]);
const { accountScopedRoute } = useAccount();
@@ -86,11 +77,6 @@ const sortedInboxes = computed(() =>
inboxes.value.slice().sort((a, b) => a.name.localeCompare(b.name))
);
const closeMobileSidebar = () => {
if (!props.isMobileSidebarOpen) return;
emit('closeMobileSidebar');
};
const newReportRoutes = () => [
{
name: 'Reports Agent',
@@ -502,19 +488,7 @@ const menuItems = computed(() => {
<template>
<aside
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
!isMobileSidebarOpen,
},
]"
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -0,0 +1,94 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (this.isTrialAccount()) {
return false;
}
return this.isLimitExceeded();
},
},
mounted() {
if (this.isOnChatwootCloud) {
this.fetchLimits();
}
},
methods: {
fetchLimits() {
this.$store.dispatch('accounts/limits');
},
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isTrialAccount() {
// check if account is less than 15 days old
const account = this.getAccount(this.accountId);
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
},
isLimitExceeded() {
const account = this.getAccount(this.accountId);
if (!account) return false;
const { limits } = account;
if (!limits) return false;
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
},
testLimit({ allowed, consumed }) {
return consumed > allowed;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@primary-action="routeToBilling"
/>
</template>
@@ -4,12 +4,7 @@ import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import wootConstants from 'dashboard/constants/globals';
defineProps({
conversationInboxType: {
type: String,
@@ -18,20 +13,12 @@ defineProps({
});
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { isEnterprise } = useConfig();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const currentChat = useMapGetter('getSelectedChat');
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
@@ -45,6 +32,7 @@ const isFeatureEnabledonAccount = useMapGetter(
);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -67,15 +55,6 @@ const activeAssistant = computed(() => {
return assistants.value[0];
});
const closeCopilotPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_copilot_panel_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
const setAssistant = async assistant => {
selectedAssistantId.value = assistant.id;
await updateUISettings({
@@ -84,9 +63,6 @@ const setAssistant = async assistant => {
};
const shouldShowCopilotPanel = computed(() => {
if (!isEnterprise) {
return false;
}
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
@@ -118,23 +94,14 @@ const sendMessage = async message => {
};
onMounted(() => {
if (isEnterprise) {
store.dispatch('captainAssistants/get');
}
store.dispatch('captainAssistants/get');
});
</script>
<template>
<div
v-if="shouldShowCopilotPanel"
v-on-click-outside="() => closeCopilotPanel()"
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': shouldShowCopilotPanel,
'md:hidden': !shouldShowCopilotPanel,
},
]"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<Copilot
:messages="messages"
@@ -15,7 +15,6 @@ import CannedResponse from '../conversation/CannedResponse.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import TagAgents from '../conversation/TagAgents.vue';
import VariableList from '../conversation/VariableList.vue';
import TagTools from '../conversation/TagTools.vue';
import { useEmitter } from 'dashboard/composables/emitter';
import { useI18n } from 'vue-i18n';
@@ -73,7 +72,6 @@ const props = defineProps({
updateSelectionWith: { type: String, default: '' },
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enableCaptainTools: { type: Boolean, default: false },
variables: { type: Object, default: () => ({}) },
enabledMenuOptions: { type: Array, default: () => [] },
signature: { type: String, default: '' },
@@ -91,7 +89,6 @@ const emit = defineEmits([
'toggleUserMention',
'toggleCannedMenu',
'toggleVariablesMenu',
'toggleToolsMenu',
'clearSelection',
'blur',
'focus',
@@ -143,9 +140,7 @@ const showUserMentions = ref(false);
const showCannedMenu = ref(false);
const showVariables = ref(false);
const showEmojiMenu = ref(false);
const showToolsMenu = ref(false);
const mentionSearchKey = ref('');
const toolSearchKey = ref('');
const cannedSearchTerm = ref('');
const variableSearchTerm = ref('');
const emojiSearchTerm = ref('');
@@ -221,17 +216,11 @@ const plugins = computed(() => {
}
return [
createSuggestionPlugin({
trigger: '@',
showMenu: showToolsMenu,
searchTerm: toolSearchKey,
isAllowed: () => props.enableCaptainTools,
}),
createSuggestionPlugin({
trigger: '@',
showMenu: showUserMentions,
searchTerm: mentionSearchKey,
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
isAllowed: () => props.isPrivate,
}),
createSuggestionPlugin({
trigger: '/',
@@ -273,9 +262,6 @@ watch(showCannedMenu, updatedValue => {
watch(showVariables, updatedValue => {
emit('toggleVariablesMenu', !props.isPrivate && updatedValue);
});
watch(showToolsMenu, updatedValue => {
emit('toggleToolsMenu', props.enableCaptainTools && updatedValue);
});
function focusEditorInputField(pos = 'end') {
const { tr } = editorView.state;
@@ -552,7 +538,6 @@ function insertSpecialContent(type, content) {
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
tool: CONVERSATION_EVENTS.INSERTED_A_TOOL,
};
useTrack(event_map[type]);
@@ -714,11 +699,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
:search-key="emojiSearchTerm"
@select-emoji="emoji => insertSpecialContent('emoji', emoji)"
/>
<TagTools
v-if="showToolsMenu"
:search-key="toolSearchKey"
@select-tool="content => insertSpecialContent('tool', content)"
/>
<input
ref="imageUpload"
type="file"
@@ -832,10 +812,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
}
}
.prosemirror-tools-node {
@apply font-medium text-n-slate-12 py-0;
}
.editor-wrap {
@apply mb-4;
}
@@ -68,10 +68,6 @@ export default {
type: Boolean,
default: false,
},
allowedContextMenuOptions: {
type: Array,
default: () => [],
},
},
emits: [
'contextMenuToggle',
@@ -155,9 +151,11 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
conversationPath() {
},
methods: {
onCardClick(e) {
const { activeInbox, chat } = this;
return frontendURL(
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
@@ -168,26 +166,18 @@ export default {
conversationType: this.conversationType,
})
);
},
},
methods: {
onCardClick(e) {
const path = this.conversationPath;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
window.chatwootConfig.hostURL + path,
'_blank',
'noopener,noreferrer'
'noopener noreferrer nofollow'
);
return;
}
// Skip if already active
if (this.isActiveChat) return;
if (this.isActiveChat) {
return;
}
router.push({ path });
},
@@ -369,8 +359,6 @@ export default {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@@ -379,7 +367,6 @@ export default {
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
@@ -2,9 +2,6 @@
import { computed } from 'vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import wootConstants from 'dashboard/constants/globals';
defineProps({
currentChat: {
@@ -13,8 +10,7 @@ defineProps({
},
});
const { uiSettings, updateUISettings } = useUISettings();
const { width: windowWidth } = useWindowSize();
const { uiSettings } = useUISettings();
const activeTab = computed(() => {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
@@ -24,31 +20,11 @@ const activeTab = computed(() => {
}
return null;
});
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const closeContactPanel = () => {
if (isSmallScreen.value && uiSettings.value?.is_contact_sidebar_open) {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
}
};
</script>
<template>
<div
v-on-click-outside="() => closeContactPanel()"
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
:class="[
{
'md:flex': activeTab === 0,
'md:hidden': activeTab !== 0,
},
]"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<div class="flex flex-1 overflow-auto">
<ContactPanel
@@ -372,7 +372,6 @@ export default {
const variables = getMessageVariables({
conversation: this.currentChat,
contact: this.currentContact,
inbox: this.inbox,
});
return variables;
},
@@ -1,56 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import ToolsDropdown from 'dashboard/components-next/captain/assistant/ToolsDropdown.vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMapGetter } from 'dashboard/composables/store.js';
const props = defineProps({
searchKey: {
type: String,
default: '',
},
});
const emit = defineEmits(['selectTool']);
const tools = useMapGetter('captainTools/getRecords');
const selectedIndex = ref(0);
const filteredTools = computed(() => {
const search = props.searchKey?.trim().toLowerCase() || '';
return tools.value.filter(tool => tool.title.toLowerCase().includes(search));
});
const adjustScroll = () => {};
const onSelect = idx => {
if (idx) selectedIndex.value = idx;
emit('selectTool', filteredTools.value[selectedIndex.value]);
};
useKeyboardNavigableList({
items: filteredTools,
onSelect,
adjustScroll,
selectedIndex,
});
watch(filteredTools, newListOfTools => {
if (newListOfTools.length < selectedIndex.value + 1) {
selectedIndex.value = 0;
}
});
</script>
<template>
<ToolsDropdown
v-if="filteredTools.length"
:items="filteredTools"
:selected-index="selectedIndex"
class="bottom-20"
@select="onSelect"
/>
<template v-else />
</template>
@@ -1,8 +1,5 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
@@ -11,20 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
components: {
@@ -53,14 +37,6 @@ export default {
type: String,
default: null,
},
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
},
emits: [
'updateConversation',
@@ -71,7 +47,6 @@ export default {
'assignTeam',
'assignLabel',
'deleteConversation',
'close',
],
setup() {
const { isAdmin } = useAdmin();
@@ -81,7 +56,6 @@ export default {
},
data() {
return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -89,7 +63,7 @@ export default {
},
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail-unread',
icon: 'mail',
},
statusMenuConfig: [
{
@@ -114,7 +88,7 @@ export default {
icon: 'snooze',
},
priorityConfig: {
key: MENU.PRIORITY,
key: 'priority',
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
@@ -141,35 +115,25 @@ export default {
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: MENU.LABEL,
key: 'label',
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: MENU.AGENT,
key: 'agent',
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: MENU.TEAM,
key: 'team',
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: MENU.DELETE,
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
};
},
computed: {
@@ -216,10 +180,6 @@ export default {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil);
},
@@ -234,24 +194,6 @@ export default {
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -275,114 +217,83 @@ export default {
</script>
<template>
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-for="option in statusMenuConfig">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
v-if="show(option.key)"
:key="option.key"
:option="option"
variant="icon"
@click.stop="$emit('markAsUnread')"
@click.stop="toggleStatus(option.key, null)"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
<template v-for="option in statusMenuConfig">
<MenuItem
v-if="showSnooze"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItemWithSubmenu :option="priorityConfig">
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="option.key"
:option="option"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
<MenuItem
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
@@ -1,16 +1,20 @@
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
option: {
type: Object,
default: () => {},
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
variant: {
type: String,
default: 'default',
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
},
});
};
</script>
<template>
@@ -26,12 +30,12 @@ defineProps({
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
<Avatar
<Thumbnail
v-if="variant === 'agent'"
:name="option.label"
:username="option.label"
:src="option.thumbnail"
:status="option.status === 'online' ? option.status : null"
:size="20"
:status="option.status"
size="20px"
class="flex-shrink-0"
/>
<p class="menu-label truncate min-w-0 flex-1">
@@ -1,14 +1,12 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -35,9 +33,7 @@ export function useCaptain() {
});
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
}
store.dispatch('accounts/limits');
};
return {
@@ -35,7 +35,7 @@ export default {
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 768,
SMALL_SCREEN_BREAKPOINT: 1200,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -6,7 +6,6 @@ export const CONVERSATION_EVENTS = Object.freeze({
TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable',
INSERTED_AN_EMOJI: 'Inserted an emoji',
INSERTED_A_TOOL: 'Inserted a tool',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
@@ -319,12 +319,6 @@ const createNode = (editorView, nodeType, content) => {
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
case 'tool': {
return state.schema.nodes.tools.create({
id: content.id,
name: content.title,
});
}
default:
return null;
}
@@ -361,11 +355,6 @@ const nodeCreators = {
from,
to,
}),
tool: (editorView, content, from, to) => ({
node: createNode(editorView, 'tool', content),
from,
to,
}),
};
/**
@@ -144,9 +144,6 @@
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -157,12 +157,6 @@
"DELETE_SUCCESS": "Portal deleted successfully",
"DELETE_ERROR": "Error while deleting portal"
}
},
"SEND_CNAME_INSTRUCTIONS": {
"API": {
"SUCCESS_MESSAGE": "CNAME instructions sent successfully",
"ERROR_MESSAGE": "Error while sending CNAME instructions"
}
}
},
"EDIT": {
@@ -753,15 +747,9 @@
"HEADER": "Custom domain",
"LABEL": "Custom domain:",
"DESCRIPTION": "You can host your portal on a custom domain. For instance, if your website is yourdomain.com and you want your portal available at docs.yourdomain.com, simply enter that in this field.",
"STATUS_DESCRIPTION": "Your custom portal will start working as soon as it is verified.",
"PLACEHOLDER": "Portal custom domain",
"EDIT_BUTTON": "Edit",
"EDIT_BUTTON": "Edit custom domain",
"ADD_BUTTON": "Add custom domain",
"STATUS": {
"LIVE": "Live",
"PENDING": "Awaiting verification",
"ERROR": "Verification failed"
},
"DIALOG": {
"ADD_HEADER": "Add custom domain",
"EDIT_HEADER": "Edit custom domain",
@@ -769,20 +757,13 @@
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
"LABEL": "Custom domain",
"PLACEHOLDER": "Portal custom domain",
"ERROR": "Custom domain is required",
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
"ERROR": "Custom domain is required"
},
"DNS_CONFIGURATION_DIALOG": {
"HEADER": "DNS configuration",
"DESCRIPTION": "Log in to the account you have with your DNS provider, and add a CNAME record for subdomain pointing to chatwoot.help",
"COPY": "Successfully copied CNAME",
"SEND_INSTRUCTIONS": {
"HEADER": "Send instructions",
"DESCRIPTION": "If you would prefer to have someone from your development team to handle this step, you can enter email address below, and we will send them the required instructions.",
"PLACEHOLDER": "Enter their email",
"ERROR": "Enter a valid email address",
"SEND_BUTTON": "Send"
}
"HELP_TEXT": "Once this is done, you can reach out to our support to request for the auto-generated SSL certificate.",
"CONFIRM_BUTTON_LABEL": "Got it!"
}
},
"DELETE_PORTAL": {
@@ -554,7 +554,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No guardrails found for this search.",
"API": {
"ADD": {
"SUCCESS": "Guardrails added successfully",
@@ -602,7 +601,6 @@
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No response guidelines found for this search.",
"API": {
"ADD": {
"SUCCESS": "Response Guidelines added successfully",
@@ -617,73 +615,6 @@
"ERROR": "There was an error deleting response guidelines, please try again."
}
}
},
"SCENARIOS": {
"TITLE": "Scenarios",
"DESCRIPTION": "Give your assistant some context—like “what to do when a user is stuck,” or “how to act during a refund request.”",
"BREADCRUMB": {
"TITLE": "Scenarios"
},
"BULK_ACTION": {
"SELECTED": "{count} item selected | {count} items selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete"
},
"ADD": {
"SUGGESTED": {
"TITLE": "Example scenarios",
"ADD": "Add all",
"ADD_SINGLE": "Add this",
"TOOLS_USED": "Tools used :"
},
"NEW": {
"CREATE": "Add a scenario",
"TITLE": "Create a scenario",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter a name for the scenario",
"ERROR": "Scenario name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Describe how and where this scenario will be used",
"ERROR": "Scenario description is required"
},
"INSTRUCTION": {
"LABEL": "How to handle",
"PLACEHOLDER": "Describe how and where this scenario will be handled",
"ERROR": "Scenario content is required"
},
"CREATE": "Create",
"CANCEL": "Cancel"
}
}
},
"UPDATE": {
"CANCEL": "Cancel",
"UPDATE": "Update changes"
},
"LIST": {
"SEARCH_PLACEHOLDER": "Search..."
},
"EMPTY_MESSAGE": "No scenarios found. Create or add examples to begin.",
"SEARCH_EMPTY_MESSAGE": "No scenarios found for this search.",
"API": {
"ADD": {
"SUCCESS": "Scenarios added successfully",
"ERROR": "There was an error adding scenarios, please try again."
},
"UPDATE": {
"SUCCESS": "Scenarios updated successfully",
"ERROR": "There was an error updating scenarios, please try again."
},
"DELETE": {
"SUCCESS": "Scenarios deleted successfully",
"ERROR": "There was an error deleting scenarios, please try again."
}
}
}
},
"DOCUMENTS": {
@@ -2,7 +2,7 @@
"AGENT_BOTS": {
"HEADER": "Boty",
"LOADING_EDITOR": "Ładowanie edytora...",
"DESCRIPTION": "Boty agentów są jak najbardziej fantastyczni członkowie Twojego zespołu. Mogą zajmować się drobnymi sprawami, dzięki czemu Ty możesz skupić się na tym, co naprawdę ważne. Wypróbuj je! Możesz zarządzać swoimi botami z tej strony lub tworzyć nowe za pomocą przycisku 'Dodaj bota'.",
"DESCRIPTION": "Agent Bots are like the most fabulous members of your team. They can handle the small stuff, so you can focus on the stuff that matters. Give them a try. You can manage your bots from this page or create new ones using the 'Add Bot' button.",
"LEARN_MORE": "Learn about agent bots",
"GLOBAL_BOT": "System bot",
"GLOBAL_BOT_BADGE": "System",
@@ -30,10 +30,10 @@
}
},
"LIST": {
"404": "Nie znaleziono botów. Możesz utworzyć bota klikając przycisk 'Dodaj bota'.",
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
"LOADING": "Pobieranie botów...",
"TABLE_HEADER": {
"DETAILS": "Szczegóły bota",
"DETAILS": "Bot Details",
"URL": "Adres URL webhooka"
}
},
@@ -8,7 +8,6 @@ import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWindowSize } from '@vueuse/core';
import wootConstants from 'dashboard/constants/globals';
@@ -19,8 +18,6 @@ const CommandBar = defineAsyncComponent(
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
export default {
components: {
NextSidebar,
@@ -30,20 +27,17 @@ export default {
UpgradePage,
CopilotLauncher,
CopilotContainer,
MobileSidebarLauncher,
},
setup() {
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
return {
uiSettings,
updateUISettings,
accountId,
upgradePageRef,
windowWidth,
};
},
data() {
@@ -51,13 +45,10 @@ export default {
showAccountModal: false,
showCreateAccountModal: false,
showShortcutModal: false,
isMobileSidebarOpen: false,
displayLayoutType: '',
};
},
computed: {
isSmallScreen() {
return this.windowWidth < wootConstants.SMALL_SCREEN_BREAKPOINT;
},
showUpgradePage() {
return this.upgradePageRef?.shouldShowUpgradePage;
},
@@ -75,30 +66,54 @@ export default {
} = this.uiSettings;
return conversationDisplayType;
},
previouslyUsedSidebarView() {
const { previously_used_sidebar_view: showSecondarySidebar } =
this.uiSettings;
return showSecondarySidebar;
},
},
watch: {
isSmallScreen: {
handler() {
const { LAYOUT_TYPES } = wootConstants;
if (window.innerWidth <= wootConstants.SMALL_SCREEN_BREAKPOINT) {
this.updateUISettings({
conversation_display_type: LAYOUT_TYPES.EXPANDED,
});
} else {
this.updateUISettings({
conversation_display_type: this.previouslyUsedDisplayType,
});
}
},
immediate: true,
displayLayoutType() {
const { LAYOUT_TYPES } = wootConstants;
this.updateUISettings({
conversation_display_type:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? LAYOUT_TYPES.EXPANDED
: this.previouslyUsedDisplayType,
show_secondary_sidebar:
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
? false
: this.previouslyUsedSidebarView,
});
},
},
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
unmounted() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
toggleMobileSidebar() {
this.isMobileSidebarOpen = !this.isMobileSidebarOpen;
},
closeMobileSidebar() {
this.isMobileSidebarOpen = false;
handleResize() {
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
let throttled = false;
const delay = 150;
if (throttled) {
return;
}
throttled = true;
setTimeout(() => {
throttled = false;
if (window.innerWidth <= SMALL_SCREEN_BREAKPOINT) {
this.displayLayoutType = LAYOUT_TYPES.EXPANDED;
} else {
this.displayLayoutType = LAYOUT_TYPES.CONDENSED;
}
}, delay);
},
openCreateAccountModal() {
this.showAccountModal = false;
@@ -121,35 +136,23 @@ export default {
</script>
<template>
<div class="flex flex-grow overflow-hidden text-n-slate-12">
<div class="flex flex-wrap app-wrapper text-n-slate-12">
<NextSidebar
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle-account-modal="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
@show-create-account-modal="openCreateAccountModal"
@close-mobile-sidebar="closeMobileSidebar"
/>
<main class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden">
<main class="flex flex-1 h-full min-h-0 px-0 overflow-hidden">
<UpgradePage
v-show="showUpgradePage"
ref="upgradePageRef"
:bypass-upgrade-page="bypassUpgradePage"
>
<MobileSidebarLauncher
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle="toggleMobileSidebar"
/>
</UpgradePage>
/>
<template v-if="!showUpgradePage">
<router-view />
<CommandBar />
<CopilotLauncher />
<MobileSidebarLauncher
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle="toggleMobileSidebar"
/>
<CopilotContainer />
</template>
<AddAccountModal
@@ -266,11 +266,6 @@ const addAllExample = () => {
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredGuardrails.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guardrail in filteredGuardrails"
@@ -284,13 +284,6 @@ const addAllExample = async () => {
{{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredGuidelines.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.SEARCH_EMPTY_MESSAGE')
}}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guideline in filteredGuidelines"
@@ -1,320 +0,0 @@
<script setup>
import { computed, h, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedScenarios from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import ScenariosCard from 'dashboard/components-next/captain/assistant/ScenariosCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import AddNewScenariosDialog from 'dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingList);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const scenarios = useMapGetter('captainScenarios/getRecords');
const searchQuery = ref('');
const breadcrumbItems = computed(() => {
return [
{
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
routeName: 'captain_assistants_index',
},
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
{ label: t('CAPTAIN.ASSISTANTS.SCENARIOS.BREADCRUMB.TITLE') },
];
});
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
const renderInstruction = instruction => () =>
h('span', {
class: 'text-sm text-n-slate-12 py-4',
innerHTML: instruction.replace(
TOOL_LINK_REGEX,
(_, title) =>
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
),
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Refund Order',
description: 'User encountered a technical issue or error message.',
instruction:
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if its a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
tools: ['create_bug_report', 'known_issues'],
},
{
id: 2,
title: 'Product Recommendation',
description: 'User is unsure which product or service to choose.',
instruction:
'Ask 23 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 23 options with pros/cons. Link to compare page if available.',
tools: ['product_match[user_needs]'],
},
];
const filteredScenarios = computed(() => {
const query = searchQuery.value.trim();
const source = scenarios.value;
if (!query) return source;
return picoSearch(source, query, ['title', 'description', 'instruction']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_scenarios_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_scenarios_suggestions: false });
};
// Bulk selection & hover state
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleRuleSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const buildSelectedCountLabel = computed(() => {
const count = scenarios.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.UNSELECT_ALL', { count })
: t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const getToolsFromInstruction = instruction => [
...new Set(
[...(instruction?.matchAll(/\(tool:\/\/([^)]+)\)/g) ?? [])].map(m => m[1])
),
];
const updateScenario = async scenario => {
try {
await store.dispatch('captainScenarios/update', {
id: scenario.id,
assistantId: route.params.assistantId,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.ERROR');
useAlert(errorMessage);
}
};
const deleteScenario = async id => {
try {
await store.dispatch('captainScenarios/delete', {
id,
assistantId: route.params.assistantId,
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.ERROR');
useAlert(errorMessage);
}
};
// TODO: Add bulk delete endpoint
const bulkDeleteScenarios = async ids => {
const idsArray = ids || Array.from(bulkSelectedIds.value);
await Promise.all(
idsArray.map(id =>
store.dispatch('captainScenarios/delete', {
id,
assistantId: route.params.assistantId,
})
)
);
bulkSelectedIds.value = new Set();
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
};
const addScenario = async scenario => {
try {
await store.dispatch('captainScenarios/create', {
assistantId: route.params.assistantId,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR');
useAlert(errorMessage);
}
};
const addAllExampleScenarios = async () => {
try {
scenariosExample.forEach(async scenario => {
await store.dispatch('captainScenarios/create', {
assistantId: route.params.assistantId,
...scenario,
});
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR');
useAlert(errorMessage);
}
};
onMounted(() => {
store.dispatch('captainScenarios/get', {
assistantId: assistantId,
});
store.dispatch('captainTools/getTools');
});
</script>
<template>
<SettingsPageLayout
:breadcrumb-items="breadcrumbItems"
:is-fetching="isFetching"
>
<template #body>
<SettingsHeader
:heading="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
:description="$t('CAPTAIN.ASSISTANTS.SCENARIOS.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedScenarios
:title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TITLE')"
:items="scenariosExample"
@close="closeSuggestedRules"
@add="addAllExampleScenarios"
>
<template #default="{ item }">
<div class="flex items-center gap-3 justify-between">
<span class="text-sm text-n-slate-12">
{{ item.title }}
</span>
<Button
:label="
$t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.ADD_SINGLE')
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addScenario(item)"
/>
</div>
<div class="flex flex-col">
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component :is="renderInstruction(item.instruction)" />
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
</template>
</SuggestedScenarios>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="scenarios"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.BULK_DELETE_BUTTON')
"
@bulk-delete="bulkDeleteScenarios"
>
<template #default-actions>
<AddNewScenariosDialog @add="addScenario" />
</template>
</BulkSelectBar>
<div
v-if="scenarios.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.LIST.SEARCH_PLACEHOLDER')
"
/>
</div>
</div>
<div v-if="scenarios.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredScenarios.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<ScenariosCard
v-for="scenario in filteredScenarios"
:id="scenario.id"
:key="scenario.id"
:title="scenario.title"
:description="scenario.description"
:instruction="scenario.instruction"
:tools="scenario.tools"
:is-selected="bulkSelectedIds.has(scenario.id)"
:selectable="
hoveredCard === scenario.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@delete="deleteScenario(scenario.id)"
@update="updateScenario"
@hover="isHovered => handleRuleHover(isHovered, scenario.id)"
/>
</div>
</div>
</template>
</SettingsPageLayout>
</template>
@@ -41,7 +41,7 @@ const controlItems = computed(() => {
description: t(
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.SCENARIOS.DESCRIPTION'
),
routeName: 'captain_assistants_scenarios_index',
// routeName: 'captain_assistants_scenarios_index',
},
{
name: t(
@@ -7,7 +7,6 @@ import AssistantEdit from './assistants/Edit.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
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';
@@ -68,21 +67,6 @@ export const routes = [
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/scenarios'
),
component: AssistantScenariosIndex,
name: 'captain_assistants_scenarios_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
@@ -60,8 +60,6 @@ export default {
:chat="conversation"
:hide-inbox-name="false"
hide-thumbnail
enable-context-menu
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
class="compact"
/>
</div>
@@ -31,7 +31,7 @@ export default {
slate
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0"
@click="toggle"
/>
</template>
@@ -68,7 +68,7 @@ export default {
<template>
<div
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 w-full min-h-screen max-w-full overflow-auto bg-n-background"
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 md:px-0 w-full min-h-screen max-w-full overflow-auto bg-n-background"
>
<div class="flex flex-col justify-start sm:justify-center gap-6">
<div class="flex flex-col gap-1.5 items-start sm:items-center">
@@ -64,7 +64,7 @@ watch(
</script>
<template>
<div class="flex w-full h-full min-h-0">
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
<section
v-if="isHelpCenterEnabled"
class="flex flex-1 h-full px-0 overflow-hidden bg-n-background"
@@ -4,16 +4,12 @@ import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue';
const SSL_STATUS_FETCH_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const { isOnChatwootCloud } = useAccount();
const { updateUISettings } = useUISettings();
@@ -28,15 +24,6 @@ const getDefaultLocale = slug => {
return getPortalBySlug.value(slug)?.meta?.default_locale;
};
const fetchSSLStatus = () => {
if (!isOnChatwootCloud.value) return;
const { portalSlug } = route.params;
store.dispatch('portals/sslStatus', {
portalSlug,
});
};
const fetchPortalAndItsCategories = async (slug, locale) => {
const selectedPortalParam = { portalSlug: slug, locale };
await Promise.all([
@@ -119,35 +106,8 @@ const deletePortal = async selectedPortalForDelete => {
}
};
const handleSendCnameInstructions = async payload => {
try {
await store.dispatch('portals/sendCnameInstructions', payload);
useAlert(
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.SUCCESS_MESSAGE'
)
);
} catch (error) {
useAlert(
error?.message ||
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.ERROR_MESSAGE'
)
);
}
};
const handleUpdatePortal = updatePortalSettings;
const handleUpdatePortalConfiguration = portalObj => {
updatePortalSettings(portalObj);
// If custom domain is added or updated, fetch SSL status after a delay of 5 seconds (only on Chatwoot cloud)
if (portalObj?.custom_domain && isOnChatwootCloud.value) {
setTimeout(() => {
fetchSSLStatus();
}, SSL_STATUS_FETCH_INTERVAL);
}
};
const handleUpdatePortalConfiguration = updatePortalSettings;
const handleDeletePortal = deletePortal;
</script>
@@ -158,7 +118,5 @@ const handleDeletePortal = deletePortal;
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
</template>
@@ -1,236 +1,221 @@
<script setup>
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
<script>
import { mapGetters } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import wootConstants from 'dashboard/constants/globals';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxCard from 'dashboard/components-next/Inbox/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { uiSettings } = useUISettings();
const notificationList = ref(null);
const page = ref(1);
const status = ref('');
const type = ref('');
const sortOrder = ref(wootConstants.INBOX_SORT_BY.NEWEST);
const isInboxContextMenuOpen = ref(false);
const infiniteLoaderOptions = computed(() => ({
root: notificationList.value,
rootMargin: '100px 0px 100px 0px',
}));
const meta = useMapGetter('notifications/getMeta');
const uiFlags = useMapGetter('notifications/getUIFlags');
const records = useMapGetter('notifications/getFilteredNotificationsV4');
const inboxById = useMapGetter('inboxes/getInboxById');
const currentConversationId = computed(() => Number(route.params.id));
const inboxFilters = computed(() => ({
page: page.value,
status: status.value,
type: type.value,
sortOrder: sortOrder.value,
}));
const notifications = computed(() => {
return records.value(inboxFilters.value);
});
const showEndOfList = computed(() => {
return uiFlags.value.isAllNotificationsLoaded && !uiFlags.value.isFetching;
});
const showEmptyState = computed(() => {
return !uiFlags.value.isFetching && !notifications.value.length;
});
const stateInbox = inboxId => {
return inboxById.value(inboxId);
};
const fetchNotifications = () => {
page.value = 1;
store.dispatch('notifications/clear');
const filter = inboxFilters.value;
store.dispatch('notifications/index', filter);
};
const scrollActiveIntoView = () => {
const activeEl = notificationList.value?.querySelector('.inbox-card.active');
activeEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
const redirectToInbox = () => {
if (route.name === 'inbox_view') return;
router.replace({ name: 'inbox_view' });
};
const loadMoreNotifications = () => {
if (uiFlags.value.isAllNotificationsLoaded) return;
page.value += 1;
store.dispatch('notifications/index', {
page: page.value,
status: status.value,
type: type.value,
sortOrder: sortOrder.value,
});
};
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: meta.value.unreadCount,
});
useAlert(t('INBOX.ALERTS.MARK_AS_READ'));
store.dispatch('notifications/unReadCount');
} catch {
// error
}
};
const markNotificationAsUnRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
redirectToInbox();
const { id } = notificationItem;
try {
await store.dispatch('notifications/unread', { id });
useAlert(t('INBOX.ALERTS.MARK_AS_UNREAD'));
store.dispatch('notifications/unReadCount');
} catch {
// error
}
};
const deleteNotification = async notificationItem => {
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
redirectToInbox();
try {
await store.dispatch('notifications/delete', {
notification: notificationItem,
unread_count: meta.value.unreadCount,
count: meta.value.count,
});
useAlert(t('INBOX.ALERTS.DELETE'));
} catch {
// error
}
};
const onFilterChange = option => {
const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
if (option.type === STATUS) {
status.value = option.selected ? option.key : '';
}
if (option.type === TYPE) {
type.value = option.selected ? option.key : '';
}
if (option.type === SORT_ORDER) {
sortOrder.value = option.key;
}
fetchNotifications();
};
const setSavedFilter = () => {
const { inbox_filter_by: filterBy = {} } = uiSettings.value;
const { status: savedStatus, type: savedType, sort_by: sortBy } = filterBy;
status.value = savedStatus;
type.value = savedType;
sortOrder.value = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
store.dispatch('notifications/setNotificationFilters', inboxFilters.value);
};
const openConversation = async notificationItem => {
const {
id,
primaryActorId,
primaryActorType,
primaryActor: { inboxId, id: conversationId },
notificationType,
} = notificationItem;
if (route.params.id === String(conversationId)) return;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: meta.value.unreadCount,
});
// to update the unread count in the store realtime
store.dispatch('notifications/unReadCount');
router.push({
name: 'inbox_view_conversation',
params: { inboxId, type: 'conversation', id: conversationId },
});
} catch {
// error
}
};
watch(
inboxFilters,
(newVal, oldVal) => {
if (newVal !== oldVal) {
store.dispatch('notifications/updateNotificationFilters', newVal);
}
export default {
components: {
InboxCard,
InboxListHeader,
IntersectionObserver,
CmdBarConversationSnooze,
Spinner,
},
{ deep: true }
);
setup() {
const { uiSettings } = useUISettings();
watch(currentConversationId, () => {
nextTick(scrollActiveIntoView);
});
return {
uiSettings,
};
},
data() {
return {
infiniteLoaderOptions: {
root: this.$refs.notificationList,
rootMargin: '100px 0px 100px 0px',
},
page: 1,
status: '',
type: '',
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
isInboxContextMenuOpen: false,
notificationIdToSnooze: null,
};
},
computed: {
...mapGetters({
meta: 'notifications/getMeta',
uiFlags: 'notifications/getUIFlags',
notification: 'notifications/getFilteredNotifications',
notificationV4: 'notifications/getFilteredNotificationsV4',
inboxById: 'inboxes/getInboxById',
}),
currentNotificationId() {
return Number(this.$route.params.notification_id);
},
inboxFilters() {
return {
page: this.page,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
};
},
notifications() {
return this.notification(this.inboxFilters);
},
notificationsV4() {
return this.notificationV4(this.inboxFilters);
},
showEndOfList() {
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
},
showEmptyState() {
return !this.uiFlags.isFetching && !this.notifications.length;
},
},
watch: {
inboxFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('notifications/updateNotificationFilters', newVal);
}
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
},
methods: {
stateInbox(inboxId) {
return this.inboxById(inboxId);
},
fetchNotifications() {
this.page = 1;
this.$store.dispatch('notifications/clear');
const filter = this.inboxFilters;
this.$store.dispatch('notifications/index', filter);
},
redirectToInbox() {
if (this.$route.name === 'inbox_view') return;
this.$router.replace({ name: 'inbox_view' });
},
loadMoreNotifications() {
if (this.uiFlags.isAllNotificationsLoaded) return;
this.$store.dispatch('notifications/index', {
page: this.page + 1,
status: this.status,
type: this.type,
sortOrder: this.sortOrder,
});
this.page += 1;
},
markNotificationAsRead(notification) {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
} = notification;
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
});
},
markNotificationAsUnRead(notification) {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
this.redirectToInbox();
const { id } = notification;
this.$store
.dispatch('notifications/unread', {
id,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
});
},
deleteNotification(notification) {
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
this.redirectToInbox();
this.$store
.dispatch('notifications/delete', {
notification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.DELETE'));
});
},
onFilterChange(option) {
const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
if (option.type === STATUS) {
this.status = option.selected ? option.key : '';
}
if (option.type === TYPE) {
this.type = option.selected ? option.key : '';
}
if (option.type === SORT_ORDER) {
this.sortOrder = option.key;
}
this.fetchNotifications();
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.status = status;
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.$store.dispatch(
'notifications/setNotificationFilters',
this.inboxFilters
);
},
openConversation(notification) {
const {
id,
primaryActorId,
primaryActorType,
primaryActor: { inboxId },
notificationType,
} = notification;
onMounted(() => {
scrollActiveIntoView();
setSavedFilter();
fetchNotifications();
});
if (this.$route.params.notification_id !== id) {
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store
.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
})
.then(() => {
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
});
this.$router.push({
name: 'inbox_view_conversation',
params: { inboxId, notification_id: id },
});
}
},
},
};
</script>
<template>
<section class="flex w-full h-full bg-n-solid-1">
<div
class="flex flex-col h-full w-full lg:min-w-[340px] lg:max-w-[340px] ltr:border-r rtl:border-l border-n-weak"
:class="!currentConversationId ? 'flex' : 'hidden xl:flex'"
class="flex flex-col h-full w-full lg:min-w-[400px] lg:max-w-[400px] ltr:border-r rtl:border-l border-n-weak"
:class="!currentNotificationId ? 'flex' : 'hidden xl:flex'"
>
<InboxListHeader
:is-context-menu-open="isInboxContextMenuOpen"
@@ -239,17 +224,17 @@ onMounted(() => {
/>
<div
ref="notificationList"
class="flex flex-col gap-0.5 w-full h-[calc(100%-56px)] pb-4 overflow-x-hidden px-2 overflow-y-auto divide-y divide-n-weak [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
class="flex flex-col gap-px w-full h-[calc(100%-56px)] pb-3 overflow-x-hidden px-3 overflow-y-auto divide-y divide-n-weak [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
>
<InboxCard
v-for="notificationItem in notifications"
v-for="notificationItem in notificationsV4"
:key="notificationItem.id"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentConversationId === notificationItem.primaryActor?.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
currentNotificationId === notificationItem.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 click-animation rounded-xl active'
: ''
"
@mark-notification-as-read="markNotificationAsRead"
@@ -279,3 +264,23 @@ onMounted(() => {
<CmdBarConversationSnooze />
</section>
</template>
<style scoped>
.click-animation {
animation: click-animation 0.2s ease-in-out;
}
@keyframes click-animation {
0% {
transform: scale(1);
}
50% {
transform: scale(0.99);
}
100% {
transform: scale(1);
}
}
</style>
@@ -1,191 +1,183 @@
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
<script>
import { mapGetters } from 'vuex';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { uiSettings } = useUISettings();
const isConversationLoading = ref(false);
const notification = useMapGetter('notifications/getFilteredNotifications');
const currentChat = useMapGetter('getSelectedChat');
const conversationById = useMapGetter('getConversationById');
const uiFlags = useMapGetter('notifications/getUIFlags');
const meta = useMapGetter('notifications/getMeta');
const inboxId = computed(() => Number(route.params.inboxId));
const conversationId = computed(() => Number(route.params.id));
const activeSortOrder = computed(() => {
const { inbox_filter_by: filterBy = {} } = uiSettings.value;
const { sort_by: sortBy } = filterBy;
return sortBy || 'desc';
});
const notifications = computed(() => {
return notification.value({
sortOrder: activeSortOrder.value,
});
});
const activeNotification = computed(() => {
return notifications.value?.find(
n => n.primary_actor?.id === conversationId.value
);
});
const totalNotificationCount = computed(() => {
return meta.value.count;
});
const showEmptyState = computed(() => {
return (
!conversationId.value ||
(!notifications.value?.length && uiFlags.value.isFetching)
);
});
const activeNotificationIndex = computed(() => {
return notifications.value?.findIndex(
n => n.primary_actor?.id === conversationId.value
);
});
const isContactPanelOpen = computed(() => {
if (currentChat.value.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
return isContactSidebarOpen;
}
return false;
});
const findConversation = () => {
return conversationById.value(conversationId.value);
};
const openNotification = async notificationItem => {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: {
meta: { unreadCount } = {},
id: conversationIdFromNotification,
},
notification_type: notificationType,
} = notificationItem;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
router.push({
name: 'inbox_view_conversation',
params: { type: 'conversation', id: conversationIdFromNotification },
});
} catch {
// error
}
};
const setActiveChat = async () => {
const selectedConversation = findConversation();
if (!selectedConversation) return;
try {
await store.dispatch('setActiveChat', { data: selectedConversation });
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
} catch {
// error
}
};
const fetchConversationById = async () => {
if (!conversationId.value) return;
store.dispatch('clearSelectedState');
const existingChat = findConversation();
if (existingChat) {
await setActiveChat();
return;
}
isConversationLoading.value = true;
try {
await store.dispatch('getConversation', conversationId.value);
await setActiveChat();
} catch {
// error
} finally {
isConversationLoading.value = false;
}
};
const navigateToConversation = (activeIndex, direction) => {
const isValidPrev = direction === 'prev' && activeIndex > 0;
const isValidNext =
direction === 'next' && activeIndex < totalNotificationCount.value - 1;
if (!isValidPrev && !isValidNext) return;
const updatedIndex = direction === 'prev' ? activeIndex - 1 : activeIndex + 1;
const targetNotification = notifications.value[updatedIndex];
if (targetNotification) {
openNotification(targetNotification);
}
};
const onClickNext = () => {
navigateToConversation(activeNotificationIndex.value, 'next');
};
const onClickPrev = () => {
navigateToConversation(activeNotificationIndex.value, 'prev');
};
watch(
conversationId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchConversationById();
}
export default {
components: {
InboxItemHeader,
InboxEmptyState,
ConversationBox,
Spinner,
},
{ immediate: true }
);
setup() {
const { uiSettings, updateUISettings } = useUISettings();
onMounted(async () => {
await store.dispatch('agents/get');
});
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
isConversationLoading: false,
};
},
computed: {
...mapGetters({
notification: 'notifications/getFilteredNotifications',
currentChat: 'getSelectedChat',
activeNotificationById: 'notifications/getNotificationById',
conversationById: 'getConversationById',
uiFlags: 'notifications/getUIFlags',
meta: 'notifications/getMeta',
}),
notifications() {
return this.notification({
sortOrder: this.activeSortOrder,
});
},
inboxId() {
return Number(this.$route.params.inboxId);
},
notificationId() {
return Number(this.$route.params.notification_id);
},
activeNotification() {
return this.activeNotificationById(this.notificationId);
},
conversationId() {
return this.activeNotification?.primary_actor?.id;
},
totalNotificationCount() {
return this.meta.count;
},
showEmptyState() {
return (
!this.conversationId ||
(!this.notifications?.length && this.uiFlags.isFetching)
);
},
activeNotificationIndex() {
return this.notifications?.findIndex(n => n.id === this.notificationId);
},
activeSortOrder() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { sort_by: sortBy } = filterBy;
return sortBy || 'desc';
},
isContactPanelOpen() {
if (this.currentChat.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } =
this.uiSettings;
return isContactSidebarOpen;
}
return false;
},
},
watch: {
conversationId: {
immediate: true,
handler(newVal, oldVal) {
if (newVal !== oldVal) {
this.fetchConversationById();
}
},
},
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
async fetchConversationById() {
if (!this.notificationId || !this.conversationId) return;
this.$store.dispatch('clearSelectedState');
const existingChat = this.findConversation();
if (existingChat) {
this.setActiveChat(existingChat);
return;
}
this.isConversationLoading = true;
await this.$store.dispatch('getConversation', this.conversationId);
this.setActiveChat();
this.isConversationLoading = false;
},
setActiveChat() {
const selectedConversation = this.findConversation();
if (!selectedConversation) return;
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
return this.conversationById(this.conversationId);
},
navigateToConversation(activeIndex, direction) {
let updatedIndex;
if (direction === 'prev' && activeIndex) {
updatedIndex = activeIndex - 1;
} else if (
direction === 'next' &&
activeIndex < this.totalNotificationCount
) {
updatedIndex = activeIndex + 1;
}
const targetNotification = this.notifications[updatedIndex];
if (targetNotification) {
this.openNotification(targetNotification);
}
},
openNotification(notification) {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { meta: { unreadCount } = {} },
notification_type: notificationType,
} = notification;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
this.$router.push({
name: 'inbox_view_conversation',
params: { notification_id: id },
});
},
onClickNext() {
this.navigateToConversation(this.activeNotificationIndex, 'next');
},
onClickPrev() {
this.navigateToConversation(this.activeNotificationIndex, 'prev');
},
onToggleContactPanel() {
this.updateUISettings({
is_contact_sidebar_open: !this.isContactPanelOpen,
});
},
},
};
</script>
<template>
<div class="h-full w-full flex-1">
<div class="h-full w-full xl:w-[calc(100%-400px)]">
<div v-if="showEmptyState" class="flex w-full h-full">
<InboxEmptyState
:empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
@@ -201,24 +193,19 @@ onMounted(async () => {
/>
<div
v-if="isConversationLoading"
class="flex items-center flex-1 my-4 justify-center bg-n-solid-1"
class="flex items-center h-[calc(100%-56px)] my-4 justify-center bg-n-solid-1"
>
<Spinner class="text-n-brand" />
</div>
<div v-else class="flex h-[calc(100%-48px)] min-w-0">
<ConversationBox
class="flex-1 [&.conversation-details-wrap]:!border-0"
is-inbox-view
:inbox-id="inboxId"
:is-on-expanded-layout="false"
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar
v-if="isContactPanelOpen"
:current-chat="currentChat"
/>
</div>
<ConversationBox
v-else
class="h-[calc(100%-56px)] [&.conversation-details-wrap]:!border-0"
is-inbox-view
:inbox-id="inboxId"
:is-contact-panel-open="isContactPanelOpen"
:is-on-expanded-layout="false"
@contact-panel-toggle="onToggleContactPanel"
/>
</div>
</div>
</template>
@@ -1,27 +1,32 @@
<script setup>
<script>
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
import MenuItem from './MenuItem.vue';
defineProps({
contextMenuPosition: {
type: Object,
default: () => ({}),
export default {
components: {
MenuItem,
ContextMenu,
},
menuItems: {
type: Array,
default: () => [],
props: {
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
},
emits: ['close', 'selectAction'],
methods: {
handleClose() {
this.$emit('close');
},
onMenuItemClick(key) {
this.$emit('selectAction', key);
this.handleClose();
},
},
});
const emit = defineEmits(['close', 'selectAction']);
const handleClose = () => {
emit('close');
};
const onMenuItemClick = key => {
emit('selectAction', key);
handleClose();
};
</script>
@@ -32,14 +37,12 @@ const onMenuItemClick = key => {
@close="handleClose"
>
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
>
<MenuItem
v-for="item in menuItems"
:key="item.key"
:option="item"
variant="icon"
class="!w-48"
:label="item.label"
@click.stop="onMenuItemClick(item.key)"
/>
</div>
@@ -109,7 +109,7 @@ export default {
<template>
<div
class="flex items-center justify-between w-full gap-2 border-b px-3 h-12 rtl:border-r border-n-weak flex-shrink-0"
class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
>
<div class="flex items-center gap-4">
<BackButton
@@ -79,9 +79,9 @@ export default {
</script>
<template>
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
<div class="flex items-center justify-between w-full gap-1 h-14 px-4 mb-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
<h1 class="min-w-0 text-lg font-medium truncate text-n-slate-12">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
@@ -21,7 +21,7 @@ export const routes = [
},
},
{
path: ':type/:id',
path: ':notification_id',
name: 'inbox_view_conversation',
component: InboxDetailView,
meta: {
@@ -1,21 +1,32 @@
<script>
import { useAdmin } from 'dashboard/composables/useAdmin';
import BackButton from '../../../components/widgets/BackButton.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BackButton,
NextButton,
},
props: {
headerTitle: {
default: '',
type: String,
},
buttonRoute: {
default: '',
type: String,
},
buttonText: {
default: '',
type: String,
},
icon: {
default: '',
type: String,
},
showBackButton: { type: Boolean, default: false },
showNewButton: { type: Boolean, default: false },
backUrl: {
type: [String, Object],
default: '',
@@ -56,5 +67,14 @@ export default {
{{ headerTitle }}
</span>
</h1>
<!-- TODO: Remove this when we are not using this -->
<router-link v-if="showNewButton && isAdmin" :to="buttonRoute">
<NextButton
teal
icon="i-lucide-circle-plus"
class="button--fixed-top"
:label="buttonText"
/>
</router-link>
</div>
</template>
@@ -2,10 +2,13 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingsHeader from './SettingsHeader.vue';
const props = defineProps({
headerTitle: { type: String, default: '' },
headerButtonText: { type: String, default: '' },
icon: { type: String, default: '' },
keepAlive: { type: Boolean, default: true },
newButtonRoutes: { type: Array, default: () => [] },
showBackButton: { type: Boolean, default: false },
backUrl: { type: [String, Object], default: '' },
fullWidth: { type: Boolean, default: false },
@@ -13,8 +16,16 @@ const props = defineProps({
const { t } = useI18n();
const showNewButton = computed(
() => props.newButtonRoutes.length && !props.showBackButton
);
const showSettingsHeader = computed(
() => props.headerTitle || props.icon || props.showBackButton
() =>
props.headerTitle ||
props.icon ||
props.showBackButton ||
showNewButton.value
);
</script>
@@ -26,10 +37,13 @@ const showSettingsHeader = computed(
>
<SettingsHeader
v-if="showSettingsHeader"
button-route="new"
:icon="icon"
:header-title="t(headerTitle)"
:button-text="t(headerButtonText)"
:show-back-button="showBackButton"
:back-url="backUrl"
:show-new-button="showNewButton"
class="sticky top-0 z-20"
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
/>
@@ -42,7 +42,9 @@ export default {
const fullWidth = params.name === 'settings_inbox_show';
return {
headerTitle: 'INBOX_MGMT.HEADER',
headerButtonText: 'SETTINGS.INBOXES.NEW_INBOX',
icon: 'mail-inbox-all',
newButtonRoutes: ['settings_inbox_list'],
showBackButton,
fullWidth,
};
@@ -41,7 +41,9 @@ export default {
props: () => {
return {
headerTitle: 'TEAMS_SETTINGS.HEADER',
headerButtonText: 'TEAMS_SETTINGS.NEW_TEAM',
icon: 'people-team',
newButtonRoutes: ['settings_teams_new'],
showBackButton: true,
};
},
@@ -4,7 +4,6 @@ import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { differenceInDays } from 'date-fns';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useI18n } from 'vue-i18n';
@@ -23,7 +22,6 @@ const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const { isAdmin } = useAdmin();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
@@ -102,54 +100,48 @@ const routeToBilling = () => {
});
};
onMounted(() => {
if (isEnterprise) {
fetchLimits();
}
});
onMounted(() => fetchLimits());
defineExpose({ shouldShowUpgradePage });
</script>
<template>
<div
v-if="shouldShowUpgradePage"
class="mx-auto h-full pt-[clamp(3rem,15vh,12rem)]"
>
<div
class="flex flex-col gap-4 max-w-md px-8 py-6 shadow-lg bg-n-solid-1 rounded-xl outline outline-1 outline-n-container"
>
<div class="flex flex-col gap-4">
<div class="flex items-center w-full gap-2">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
>
<Icon
class="flex-shrink-0 text-n-brand size-[14px]"
icon="i-lucide-lock-keyhole"
/>
</span>
<span class="text-base font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.UPGRADE') }}
</span>
</div>
<div>
<p class="text-sm font-normal text-n-slate-11 mb-3">
{{ limitExceededMessage }}
</p>
<p v-if="!isAdmin">
{{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</p>
<template v-if="shouldShowUpgradePage">
<div class="mx-auto h-full pt-[clamp(3rem,15vh,12rem)]">
<div
class="flex flex-col gap-4 max-w-md px-8 py-6 shadow-lg bg-n-solid-1 rounded-xl outline outline-1 outline-n-container"
>
<div class="flex flex-col gap-4">
<div class="flex items-center w-full gap-2">
<span
class="flex items-center justify-center w-6 h-6 rounded-full bg-n-solid-blue"
>
<Icon
class="flex-shrink-0 text-n-brand size-[14px]"
icon="i-lucide-lock-keyhole"
/>
</span>
<span class="text-base font-medium text-n-slate-12">
{{ $t('GENERAL_SETTINGS.UPGRADE') }}
</span>
</div>
<div>
<p class="text-sm font-normal text-n-slate-11 mb-3">
{{ limitExceededMessage }}
</p>
<p v-if="!isAdmin">
{{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</p>
</div>
</div>
<NextButton
v-if="isAdmin"
:label="$t('GENERAL_SETTINGS.OPEN_BILLING')"
icon="i-lucide-credit-card"
@click="routeToBilling()"
/>
</div>
<NextButton
v-if="isAdmin"
:label="$t('GENERAL_SETTINGS.OPEN_BILLING')"
icon="i-lucide-credit-card"
@click="routeToBilling()"
/>
</div>
<slot />
</div>
<div v-else />
</template>
<template v-else />
</template>
@@ -1,38 +0,0 @@
import CaptainScenarios from 'dashboard/api/captain/scenarios';
import { createStore } from './storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainScenario',
API: CaptainScenarios,
actions: mutations => ({
update: async ({ commit }, { id, assistantId, ...updateObj }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainScenarios.update(
{ id, assistantId },
updateObj
);
commit(mutations.EDIT, response.data);
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
delete: async ({ commit }, { id, assistantId }) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainScenarios.delete({ id, assistantId });
commit(mutations.DELETE, id);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return id;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});
@@ -1,24 +0,0 @@
import { createStore } from './storeFactory';
import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
name: 'captainTool',
API: CaptainToolsAPI,
actions: mutations => ({
getTools: async ({ commit }) => {
commit(mutations.SET_UI_FLAG, { fetchingList: true });
try {
const response = await CaptainToolsAPI.get();
commit(mutations.SET, response.data);
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return throwErrorMessage(error);
}
},
}),
});
export default toolsStore;
-4
View File
@@ -53,8 +53,6 @@ import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
const plugins = [];
@@ -113,8 +111,6 @@ export default createStore({
captainBulkActions,
copilotThreads,
copilotMessages,
captainScenarios,
captainTools,
},
plugins,
});
@@ -116,24 +116,4 @@ export const actions = {
isSwitching,
});
},
sendCnameInstructions: async (_, { portalSlug, email }) => {
try {
await portalAPIs.sendCnameInstructions(portalSlug, email);
} catch (error) {
throwErrorMessage(error);
}
},
sslStatus: async ({ commit }, { portalSlug }) => {
try {
commit(types.SET_UI_FLAG, { isFetchingSSLStatus: true });
const { data } = await portalAPIs.sslStatus(portalSlug);
commit(types.SET_SSL_SETTINGS, { portalSlug, sslSettings: data });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isFetchingSSLStatus: false });
}
},
};
@@ -8,7 +8,6 @@ export const getters = {
isFetchingPortals: state => state.uiFlags.isFetching,
isCreatingPortal: state => state.uiFlags.isCreating,
isSwitchingPortal: state => state.uiFlags.isSwitching,
isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus,
portalBySlug:
(...getterArguments) =>
portalId => {
@@ -6,7 +6,6 @@ export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
};
const state = {
@@ -13,7 +13,6 @@ export const types = {
REMOVE_PORTAL_ID: 'removePortalId',
SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag',
SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag',
SET_SSL_SETTINGS: 'setSSLSettings',
};
export const mutations = {
@@ -111,18 +110,4 @@ export const mutations = {
[types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) {
$state.uiFlags.isSwitching = isSwitching;
},
[types.SET_SSL_SETTINGS]($state, { portalSlug, sslSettings }) {
const portal = $state.portals.byId[portalSlug];
$state.portals.byId = {
...$state.portals.byId,
[portalSlug]: {
...portal,
ssl_settings: {
...portal.ssl_settings,
...sslSettings,
},
},
};
},
};
@@ -135,36 +135,6 @@ describe('#actions', () => {
});
});
describe('#sslStatus', () => {
it('commits SET_SSL_SETTINGS with data from API', async () => {
axios.get.mockResolvedValue({
data: { status: 'active', verification_errors: [] },
});
await actions.sslStatus({ commit }, { portalSlug: 'domain' });
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
[
types.SET_SSL_SETTINGS,
{
portalSlug: 'domain',
sslSettings: { status: 'active', verification_errors: [] },
},
],
[types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
]);
});
it('throws error and does not commit when API fails', async () => {
axios.get.mockRejectedValue({ message: 'error' });
await expect(
actions.sslStatus({ commit }, { portalSlug: 'domain' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
[types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
@@ -89,25 +89,6 @@ describe('#mutations', () => {
isFetching: true,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
});
});
});
describe('[types.SET_SSL_SETTINGS]', () => {
it('merges new ssl settings into existing portal.ssl_settings', () => {
state.portals.byId.domain = {
slug: 'domain',
ssl_settings: { cf_status: 'pending' },
};
mutations[types.SET_SSL_SETTINGS](state, {
portalSlug: 'domain',
sslSettings: { status: 'active', verification_errors: ['error'] },
});
expect(state.portals.byId.domain.ssl_settings).toEqual({
cf_status: 'pending',
status: 'active',
verification_errors: ['error'],
});
});
});
@@ -157,14 +157,6 @@ export const MESSAGE_VARIABLES = [
label: 'Agent email',
key: 'agent.email',
},
{
key: 'inbox.name',
label: 'Inbox name',
},
{
label: 'Inbox id',
key: 'inbox.id',
},
];
export const ATTACHMENT_ICONS = {
+36
View File
@@ -0,0 +1,36 @@
# frozen_string_literal: true
class AssignmentV2::AssignmentJob < ApplicationJob
queue_as :low
def perform(inbox_id: nil, conversation_id: nil)
if conversation_id
assign_single_conversation(conversation_id)
elsif inbox_id
assign_inbox_conversations(inbox_id)
else
Rails.logger.error 'AssignmentV2::AssignmentJob: No inbox_id or conversation_id provided'
end
end
private
def assign_single_conversation(conversation_id)
conversation = Conversation.find_by(id: conversation_id)
return unless conversation
service = AssignmentV2::AssignmentService.new(inbox: conversation.inbox)
service.perform_for_conversation(conversation)
end
def assign_inbox_conversations(inbox_id)
inbox = Inbox.find_by(id: inbox_id)
return unless inbox
return unless inbox.assignment_v2_enabled?
service = AssignmentV2::AssignmentService.new(inbox: inbox)
assigned_count = service.perform_bulk_assignment
Rails.logger.info "AssignmentV2::AssignmentJob: Assigned #{assigned_count} conversations for inbox #{inbox_id}"
end
end
@@ -1,6 +1,5 @@
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
@@ -12,13 +11,6 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
private
def should_fetch_emails?(inbox)
return false if inbox.account.suspended?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
return true unless ChatwootApp.chatwoot_cloud?
return false if default_plan?(inbox.account)
true
inbox.channel.imap_enabled && !inbox.account.suspended?
end
end
+48
View File
@@ -0,0 +1,48 @@
# frozen_string_literal: true
class ReassignConversationsJob < ApplicationJob
queue_as :low
def perform(account_user)
return unless account_user
user = account_user.user
account = account_user.account
# Find all open conversations assigned to this user
conversations = account.conversations
.open
.where(assignee: user)
Rails.logger.info "Reassigning #{conversations.count} conversations for user #{user.name} (#{user.id}) on leave"
conversations.find_each do |conversation|
reassign_conversation(conversation)
end
end
private
def reassign_conversation(conversation)
inbox = conversation.inbox
# Use Assignment V2 if enabled
if inbox.assignment_v2_enabled?
assignment_service = AssignmentV2::AssignmentService.new(inbox: inbox)
# Mark conversation as unassigned first
conversation.update!(assignee: nil)
# Let the assignment service handle it
assignment_service.perform_for_conversation(conversation)
else
# Fallback to auto assignment
AutoAssignmentService.new(
conversation: conversation,
allowed_agent_ids: inbox.assignable_agents.map(&:id) - [conversation.assignee_id]
).perform
end
rescue StandardError => e
Rails.logger.error "Failed to reassign conversation #{conversation.id}: #{e.message}"
end
end
+1 -19
View File
@@ -37,11 +37,10 @@ class HookListener < BaseListener
private
def execute_hooks(event, message)
message.account.hooks.find_each do |hook|
message.account.hooks.each do |hook|
# In case of dialogflow, we would have a hook for each inbox.
# Which means we will execute the same hook multiple times if the below filter isn't there
next if hook.inbox.present? && hook.inbox != message.inbox
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, message: message)
end
@@ -49,24 +48,7 @@ class HookListener < BaseListener
def execute_account_hooks(event, account, event_data = {})
account.hooks.account_hooks.find_each do |hook|
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, event_data)
end
end
def supported_hook_event?(hook, event_name)
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
}
return false unless supported_events_map.key?(hook.app_id)
supported_events_map[hook.app_id].include?(event_name)
end
end
@@ -4,17 +4,16 @@ module IncomingEmailValidityHelper
def incoming_email_from_valid_email?
return false unless valid_external_email_for_active_account?
# Return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
# Process bounced emails, as regular emails
return true if @processed_mail.bounced?
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
# return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
# TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
# The returned value could be "\"\"" for some email clients
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
true
end
-41
View File
@@ -1,41 +0,0 @@
class PortalInstructionsMailer < ApplicationMailer
def send_cname_instructions(portal:, recipient_email:)
return unless smtp_config_set_or_development?
return if target_domain.blank?
@portal = portal
@cname_record = generate_cname_record
send_mail_with_liquid(
to: recipient_email,
subject: I18n.t('portals.send_instructions.subject', custom_domain: @portal.custom_domain)
)
end
private
def liquid_locals
super.merge({ cname_record: @cname_record })
end
def generate_cname_record
"#{@portal.custom_domain} CNAME #{target_domain}"
end
def target_domain
helpcenter_url = ENV.fetch('HELPCENTER_URL', '')
frontend_url = ENV.fetch('FRONTEND_URL', '')
return extract_hostname(helpcenter_url) if helpcenter_url.present?
return extract_hostname(frontend_url) if frontend_url.present?
''
end
def extract_hostname(url)
uri = URI.parse(url)
uri.host
rescue URI::InvalidURIError
url.gsub(%r{https?://}, '').split('/').first
end
end
+6
View File
@@ -28,6 +28,7 @@ class Account < ApplicationRecord
include Reportable
include Featurable
include CacheKeys
include AssignmentV2FeatureFlag
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
@@ -97,6 +98,11 @@ class Account < ApplicationRecord
has_many :webhooks, dependent: :destroy_async
has_many :whatsapp_channels, dependent: :destroy_async, class_name: '::Channel::Whatsapp'
has_many :working_hours, dependent: :destroy_async
has_many :leaves, dependent: :destroy_async, class_name: 'Leave'
# Assignment V2 associations
has_many :assignment_policies, dependent: :destroy_async
has_many :agent_capacity_policies, dependent: :destroy_async, class_name: 'Enterprise::AgentCapacityPolicy' if ChatwootApp.enterprise?
has_one_attached :contacts_export
+20 -15
View File
@@ -2,24 +2,26 @@
#
# Table name: account_users
#
# id :bigint not null, primary key
# active_at :datetime
# auto_offline :boolean default(TRUE), not null
# availability :integer default("online"), not null
# role :integer default("agent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# custom_role_id :bigint
# inviter_id :bigint
# user_id :bigint
# id :bigint not null, primary key
# active_at :datetime
# auto_offline :boolean default(TRUE), not null
# availability :integer default("online"), not null
# role :integer default("agent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# agent_capacity_policy_id :bigint
# custom_role_id :bigint
# inviter_id :bigint
# user_id :bigint
#
# Indexes
#
# index_account_users_on_account_id (account_id)
# index_account_users_on_custom_role_id (custom_role_id)
# index_account_users_on_user_id (user_id)
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
# index_account_users_on_account_id (account_id)
# index_account_users_on_agent_capacity_policy_id (agent_capacity_policy_id)
# index_account_users_on_custom_role_id (custom_role_id)
# index_account_users_on_user_id (user_id)
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
#
class AccountUser < ApplicationRecord
@@ -28,6 +30,9 @@ class AccountUser < ApplicationRecord
belongs_to :account
belongs_to :user
belongs_to :inviter, class_name: 'User', optional: true
belongs_to :agent_capacity_policy, class_name: 'Enterprise::AgentCapacityPolicy', optional: true
has_many :leaves, dependent: :destroy, class_name: 'Leave'
enum role: { agent: 0, administrator: 1 }
enum availability: { online: 0, offline: 1, busy: 2 }
+103
View File
@@ -0,0 +1,103 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: assignment_policies
#
# id :bigint not null, primary key
# assignment_order :integer default("round_robin"), not null
# conversation_priority :integer default("earliest_created"), not null
# description :text
# enabled :boolean default(TRUE), not null
# fair_distribution_limit :integer default(10), not null
# fair_distribution_window :integer default(3600), not null
# name :string(255) not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_assignment_policies_on_account_id (account_id)
# index_assignment_policies_on_enabled (enabled)
# unique_assignment_policy_name_per_account (account_id,name) UNIQUE
#
class AssignmentPolicy < ApplicationRecord
include AccountCacheRevalidator
# Enums
enum assignment_order: { round_robin: 0, balanced: 1 }
enum conversation_priority: { earliest_created: 0, longest_waiting: 1 }
# Associations
belongs_to :account
has_many :inbox_assignment_policies, dependent: :destroy
has_many :inboxes, through: :inbox_assignment_policies
# Validations
validates :name, presence: true, uniqueness: { scope: :account_id }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1000 }
validates :fair_distribution_limit, presence: true, numericality: { greater_than: 0, less_than_or_equal_to: 100 }
validates :fair_distribution_window, presence: true, numericality: { greater_than: 60, less_than_or_equal_to: 86_400 }
validates :assignment_order, inclusion: { in: assignment_orders.keys }
validates :conversation_priority, inclusion: { in: conversation_priorities.keys }
# Validate balanced assignment is only available for enterprise
validate :validate_balanced_assignment_enterprise_only
# Server-side validation to prevent bypass
before_save :enforce_enterprise_features
# Scopes
scope :enabled, -> { where(enabled: true) }
scope :disabled, -> { where(enabled: false) }
# Callbacks
after_update_commit :clear_assignment_caches
after_destroy :clear_assignment_caches
def can_use_balanced_assignment?
account.feature_enabled?(:enterprise_agent_capacity) if account.respond_to?(:feature_enabled?)
end
def webhook_data
{
id: id,
name: name,
description: description,
assignment_order: assignment_order,
conversation_priority: conversation_priority,
fair_distribution_limit: fair_distribution_limit,
fair_distribution_window: fair_distribution_window,
enabled: enabled
}
end
private
def validate_balanced_assignment_enterprise_only
return unless balanced?
return if can_use_balanced_assignment?
errors.add(:assignment_order, 'Balanced assignment is only available for enterprise accounts')
end
def enforce_enterprise_features
# Server-side enforcement to prevent API bypass
return unless balanced? && !can_use_balanced_assignment?
# Force to round_robin if enterprise not available
self.assignment_order = 'round_robin'
Rails.logger.warn("Assignment V2: Forced assignment_order to round_robin for non-enterprise account #{account_id}")
end
def clear_assignment_caches
# Clear Redis caches when policy is updated
Rails.cache.delete("assignment_v2:policy:#{id}")
inboxes.find_each do |inbox|
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox.id}")
end
end
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
module AssignmentV2FeatureFlag
extend ActiveSupport::Concern
def assignment_v2_enabled?
# Check account-level feature flag
return false unless feature_enabled_for_account?
# Check for any inbox-level overrides
return false if inbox_level_override_disabled?
# Check system-wide killswitch
!GlobalConfig.get('assignment_v2_disabled', false)
end
private
def feature_enabled_for_account?
config = GlobalConfig.get('assignment_v2')
return false unless config&.dig('enabled')
# If no account allowlist, enable for all
allowed_accounts = config['accounts']
return true if allowed_accounts.blank?
# Check if account is in allowlist
allowed_accounts.include?(id)
end
def inbox_level_override_disabled?
return false unless respond_to?(:id)
# Allow per-inbox disabling during migration
GlobalConfig.get('assignment_v2_disabled_inboxes', []).include?(id)
end
end
@@ -14,11 +14,18 @@ module AutoAssignmentHandler
return unless conversation_status_changed_to_open?
return unless should_run_auto_assignment?
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
if inbox.assignment_v2_enabled?
# Use Assignment V2 system
AssignmentV2::AssignmentJob.perform_later(conversation_id: id)
else
# Use legacy assignment system
::AutoAssignment::AgentAssignmentService.new(conversation: self, allowed_agent_ids: inbox.member_ids_with_assignment_capacity).perform
end
end
def should_run_auto_assignment?
return false unless inbox.enable_auto_assignment?
# Check auto assignment is enabled (either legacy or v2)
return false unless inbox.auto_assignment_enabled?
# run only if assignee is blank or doesn't have access to inbox
assignee.blank? || inbox.members.exclude?(assignee)
@@ -0,0 +1,128 @@
# frozen_string_literal: true
module InboxAgentAvailability
extend ActiveSupport::Concern
def available_agents(options = {})
options = { check_capacity: true }.merge(options)
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
def member_ids_with_assignment_capacity
return member_ids unless assignment_v2_enabled? && enterprise_capacity_enabled?
available_agents(check_capacity: true).pluck(:user_id)
end
private
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply capacity filtering for enterprise accounts
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
# Apply rate limiting if implemented
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
# Exclude agents who are on leave
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
scope
end
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def enterprise_capacity_enabled?
defined?(Enterprise) &&
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
end
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_check_required?
assignment_counts = fetch_assignment_counts
inbox_members_scope.select do |inbox_member|
agent_has_capacity?(inbox_member, assignment_counts)
end
end
def capacity_check_required?
defined?(Enterprise::InboxCapacityLimit) &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def fetch_assignment_counts
conversations
.where(status: :open)
.where.not(assignee_id: nil)
.group(:assignee_id)
.count
end
def agent_has_capacity?(inbox_member, assignment_counts)
user = inbox_member.user
account_user = account.account_users.find_by(user: user)
return true unless account_user&.agent_capacity_policy_id
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
return true unless capacity_limit&.conversation_limit
current_count = assignment_counts[user.id] || 0
current_count < capacity_limit.conversation_limit
end
def fetch_capacity_limit(policy_id)
Enterprise::InboxCapacityLimit
.where(agent_capacity_policy_id: policy_id)
.find_by(inbox_id: id)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
inbox_members_scope.select do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
rate_limiter.within_limits?
end
end
def filter_agents_on_leave(inbox_members_scope)
return inbox_members_scope unless defined?(Enterprise::AgentLeave)
# Filter out agents who are currently on leave
on_leave_user_ids = Enterprise::AgentLeave
.active
.where(account_id: account_id)
.pluck(:user_id)
return inbox_members_scope if on_leave_user_ids.empty?
inbox_members_scope.where.not(user_id: on_leave_user_ids)
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
module InboxChannelTypes
extend ActiveSupport::Concern
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def inbox_type
channel.name
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
end
end
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module InboxNameSanitization
extend ActiveSupport::Concern
included do
before_validation :sanitize_name, unless: :new_record?
before_save :ensure_name_present
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return handle_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
return sanitized if sanitized.present?
email? ? (display_name_from_email || '') : ''
end
private
def handle_blank_name
email? ? (display_name_from_email || '') : ''
end
def sanitize_name
self.name = apply_sanitization_rules(name) if name.present?
end
def ensure_name_present
self.name = default_name_for_blank_name if name.blank?
self.name = apply_sanitization_rules(name) if name.present?
end
def default_name_for_blank_name
return channel.try(:bot_name) if web_widget?
readable_name = display_name_from_email if email?
readable_name ||= 'Inbox'
"#{readable_name} #{SecureRandom.hex(4)}"
end
def apply_sanitization_rules(name)
# Remove forbidden characters and spam-trigger symbols
# Keep: letters, numbers, spaces, /'._- and Unicode characters (including emojis)
sanitized = name.gsub(/[\\<>@"!#$%&*+=?^`{|}~;:]/, '')
# Normalize whitespace
sanitized = sanitized.gsub(/\s+/, ' ')
# Remove leading and trailing non-word characters (but keep Unicode including emojis)
# Use negative lookahead to exclude emoji ranges
sanitized = sanitized.gsub(%r{\A[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+|[^\p{L}\p{N}\p{So}\p{Sc}\s'/_.-]+\z}, '')
sanitized.strip
end
def display_name_from_email
email_address = channel.try(:imap_email) || channel.try(:email)
return nil unless email_address
local_part = email_address.split('@').first
return nil unless local_part
# Convert underscores and hyphens to spaces and capitalize each word
local_part.gsub(/[_-]/, ' ').split.map(&:capitalize).join(' ')
end
end
+25
View File
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module InboxWebhooks
extend ActiveSupport::Concern
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
end
end
end
+153 -86
View File
@@ -44,6 +44,11 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
include AccountCacheRevalidator
include AssignmentV2FeatureFlag
include InboxAgentAvailability
include InboxChannelTypes
include InboxWebhooks
include InboxNameSanitization
# Not allowing characters:
validates :name, presence: true
@@ -72,6 +77,10 @@ class Inbox < ApplicationRecord
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
# Assignment V2 associations
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy
enum sender_name_type: { friendly: 0, professional: 1 }
after_destroy :delete_round_robin_agents
@@ -97,109 +106,167 @@ class Inbox < ApplicationRecord
update_account_cache
end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms?
channel_type == 'Channel::Sms'
end
def facebook?
channel_type == 'Channel::FacebookPage'
end
def instagram?
(facebook? || instagram_direct?) && channel.instagram_id.present?
end
def instagram_direct?
channel_type == 'Channel::Instagram'
end
def web_widget?
channel_type == 'Channel::WebWidget'
end
def api?
channel_type == 'Channel::Api'
end
def email?
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def twitter?
channel_type == 'Channel::TwitterProfile'
end
def whatsapp?
channel_type == 'Channel::Whatsapp'
end
def assignable_agents
(account.users.where(id: members.select(:user_id)) + account.administrators).uniq
end
def active_bot?
agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow],
status: 'enabled').count.positive?
# Assignment V2 methods
def assignment_v2_enabled?
account.assignment_v2_enabled? && assignment_policy.present? && assignment_policy.enabled?
end
def inbox_type
channel.name
end
def webhook_data
{
id: id,
name: name
}
end
def callback_webhook_url
case channel_type
when 'Channel::TwilioSms'
"#{ENV.fetch('FRONTEND_URL', nil)}/twilio/callback"
when 'Channel::Sms'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/sms/#{channel.phone_number.delete_prefix('+')}"
when 'Channel::Line'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/line/#{channel.line_channel_id}"
when 'Channel::Whatsapp'
"#{ENV.fetch('FRONTEND_URL', nil)}/webhooks/whatsapp/#{channel.phone_number}"
def auto_assignment_enabled?
if assignment_v2_enabled?
assignment_policy.present? && assignment_policy.enabled?
else
enable_auto_assignment?
end
end
def member_ids_with_assignment_capacity
members.ids
# Returns inbox members who are available for assignment
# This method performs all filtering upfront at the database level for optimal performance
#
# Filters applied:
# 1. Online status - Only agents marked as 'online' in OnlineStatusTracker
# 2. Capacity limits (Enterprise) - Agents who haven't reached their conversation limit
# 3. Rate limiting - Agents who haven't exceeded rate limits (when implemented)
# 4. User exclusions - Specific users can be excluded (e.g., for reassignment)
#
# @param options [Hash] Additional filter options
# @option options [Boolean] :check_capacity (true) Whether to check capacity limits
# @option options [Boolean] :check_rate_limits (false) Whether to check rate limits
# @option options [Array<Integer>] :exclude_user_ids Users to exclude from results
#
# @return [ActiveRecord::Relation<InboxMember>] Available inbox members with preloaded users
#
# @example Get all available agents
# inbox.available_agents
#
# @example Get available agents excluding specific users
# inbox.available_agents(exclude_user_ids: [1, 2, 3])
#
# @example Get available agents without capacity check (faster but less accurate)
# inbox.available_agents(check_capacity: false)
def available_agents(options = {})
options = { check_capacity: true }.merge(options)
# Get online agent IDs
online_agent_ids = fetch_online_agent_ids
return inbox_members.none if online_agent_ids.empty?
# Base query - only online agents
scope = build_online_agents_scope(online_agent_ids)
# Apply filters
apply_agent_filters(scope, options)
end
private
def default_name_for_blank_name
email? ? display_name_from_email : ''
def build_online_agents_scope(online_agent_ids)
inbox_members
.joins(:user)
.where(users: { id: online_agent_ids })
.includes(:user)
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
def apply_agent_filters(scope, options)
# Exclude specific users if requested
scope = scope.where.not(users: { id: options[:exclude_user_ids] }) if options[:exclude_user_ids].present?
# Apply capacity filtering for enterprise accounts
scope = filter_by_capacity(scope) if options[:check_capacity] && enterprise_capacity_enabled?
# Apply rate limiting if implemented
scope = filter_by_rate_limits(scope) if options[:check_rate_limits] && defined?(AssignmentV2::RateLimiter)
# Exclude agents who are on leave
scope = filter_agents_on_leave(scope) if options[:exclude_on_leave] != false
scope
end
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
def fetch_online_agent_ids
OnlineStatusTracker.get_available_users(account_id)
.select { |_key, value| value.eql?('online') }
.keys
.map(&:to_i)
end
def enterprise_capacity_enabled?
defined?(Enterprise) &&
account.custom_attributes&.dig('enterprise_features', 'capacity_management').present?
end
def filter_by_capacity(inbox_members_scope)
return inbox_members_scope unless capacity_check_required?
assignment_counts = fetch_assignment_counts
inbox_members_scope.select do |inbox_member|
agent_has_capacity?(inbox_member, assignment_counts)
end
end
def capacity_check_required?
defined?(Enterprise::InboxCapacityLimit) &&
account.account_users.joins(:agent_capacity_policy).exists?
end
def fetch_assignment_counts
conversations
.where(status: :open)
.where.not(assignee_id: nil)
.group(:assignee_id)
.count
end
def agent_has_capacity?(inbox_member, assignment_counts)
user = inbox_member.user
account_user = account.account_users.find_by(user: user)
return true unless account_user&.agent_capacity_policy_id
capacity_limit = fetch_capacity_limit(account_user.agent_capacity_policy_id)
return true unless capacity_limit&.conversation_limit
current_count = assignment_counts[user.id] || 0
current_count < capacity_limit.conversation_limit
end
def fetch_capacity_limit(policy_id)
Enterprise::InboxCapacityLimit
.where(agent_capacity_policy_id: policy_id)
.find_by(inbox_id: id)
end
def filter_by_rate_limits(inbox_members_scope)
# Filter out agents who have exceeded rate limits
return inbox_members_scope unless assignment_policy&.enabled?
# Get IDs of inbox members within rate limits
valid_inbox_member_ids = inbox_members_scope.find_each.filter_map do |inbox_member|
rate_limiter = AssignmentV2::RateLimiter.new(inbox: self, user: inbox_member.user)
inbox_member.id if rate_limiter.within_limits?
end
# Return filtered scope maintaining ActiveRecord relation
inbox_members_scope.where(id: valid_inbox_member_ids)
end
def filter_agents_on_leave(inbox_members_scope)
# Get account users on active leave
account_user_ids_on_leave = account.account_users
.joins(:leaves)
.where(leaves: { status: 'approved' })
.where('leaves.start_date <= ? AND leaves.end_date >= ?', Date.current, Date.current)
.pluck(:id)
return inbox_members_scope if account_user_ids_on_leave.empty?
# Exclude inbox members whose account_users are on leave
user_ids_on_leave = account.account_users.where(id: account_user_ids_on_leave).pluck(:user_id)
inbox_members_scope.where.not(user_id: user_ids_on_leave)
end
def dispatch_create_event
+67
View File
@@ -0,0 +1,67 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: inbox_assignment_policies
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
# assignment_policy_id :bigint not null
# inbox_id :bigint not null
#
# Indexes
#
# index_inbox_assignment_policies_on_assignment_policy_id (assignment_policy_id)
# index_inbox_assignment_policies_on_inbox_id (inbox_id)
# unique_inbox_assignment_policy (inbox_id) UNIQUE
#
class InboxAssignmentPolicy < ApplicationRecord
include AccountCacheRevalidator
# Associations
belongs_to :inbox
belongs_to :assignment_policy
# Validations
validates :inbox_id, uniqueness: true
validate :inbox_belongs_to_same_account
# Delegations
delegate :account, to: :inbox
delegate :name, :description, :assignment_order, :conversation_priority,
:fair_distribution_limit, :fair_distribution_window, :enabled?,
to: :assignment_policy, prefix: :policy
# Callbacks
after_commit :clear_inbox_cache
# Scopes
scope :enabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: true }) }
scope :disabled, -> { joins(:assignment_policy).where(assignment_policies: { enabled: false }) }
def webhook_data
{
id: id,
inbox_id: inbox_id,
assignment_policy_id: assignment_policy_id,
policy: assignment_policy.webhook_data
}
end
private
def inbox_belongs_to_same_account
return unless inbox && assignment_policy
return if inbox.account_id == assignment_policy.account_id
errors.add(:inbox, 'must belong to the same account as the assignment policy')
end
def clear_inbox_cache
Rails.cache.delete("assignment_v2:inbox_policy:#{inbox_id}")
update_account_cache
end
end
+108
View File
@@ -0,0 +1,108 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: leaves
#
# id :bigint not null, primary key
# approved_at :datetime
# end_date :date not null
# leave_type :integer default("vacation"), not null
# reason :text
# start_date :date not null
# status :integer default("pending"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# account_user_id :bigint not null
# approved_by_id :bigint
#
# Indexes
#
# index_leaves_on_account_and_status (account_id,status)
# index_leaves_on_account_id (account_id)
# index_leaves_on_account_user_and_dates (account_user_id,start_date,end_date)
# index_leaves_on_account_user_id (account_user_id)
# index_leaves_on_approved_by_id (approved_by_id)
# index_leaves_on_end_date (end_date)
# index_leaves_on_start_date (start_date)
# index_leaves_on_status (status)
#
class Leave < ApplicationRecord
belongs_to :account
belongs_to :account_user
belongs_to :approved_by, class_name: 'User', optional: true
has_one :user, through: :account_user
enum leave_type: {
vacation: 0,
sick: 1,
personal: 2,
maternity: 3,
paternity: 4,
bereavement: 5,
unpaid: 6
}
enum status: {
pending: 0,
approved: 1,
rejected: 2,
cancelled: 3
}
validates :start_date, presence: true
validates :end_date, presence: true
validates :leave_type, presence: true
validates :status, presence: true
validate :end_date_after_start_date
validate :no_overlapping_leaves, if: :approved?
scope :active, -> { approved.where('start_date <= ? AND end_date >= ?', Date.current, Date.current) }
scope :upcoming, -> { approved.where('start_date > ?', Date.current) }
scope :past, -> { where('end_date < ?', Date.current) }
scope :by_date_range, ->(start_date, end_date) { where('start_date <= ? AND end_date >= ?', end_date, start_date) }
before_update :set_approved_at, if: -> { status_changed? && approved? }
def active?
approved? && start_date <= Date.current && end_date >= Date.current
end
def days_count
return 0 unless start_date && end_date
(end_date - start_date).to_i + 1
end
def overlaps_with?(other_leave)
return false if other_leave == self
start_date <= other_leave.end_date && end_date >= other_leave.start_date
end
private
def end_date_after_start_date
return unless start_date && end_date
errors.add(:end_date, 'must be after or equal to start date') if end_date < start_date
end
def no_overlapping_leaves
overlapping_leaves = account_user.leaves
.approved
.where.not(id: id)
.by_date_range(start_date, end_date)
return unless overlapping_leaves.exists?
errors.add(:base, 'Leave dates overlap with an existing approved leave')
end
def set_approved_at
self.approved_at = Time.current
end
end

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