Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fbb93698 | ||
|
|
38d174bda6 | ||
|
|
7ca5d365ae | ||
|
|
6be85a40f7 | ||
|
|
922c989574 | ||
|
|
59928bd85c | ||
|
|
661e905dbd | ||
|
|
270f26e471 | ||
|
|
53fce7be03 | ||
|
|
60a1e9b15d | ||
|
|
65312744c7 | ||
|
|
46c55a53bd | ||
|
|
176e09cde7 | ||
|
|
b09c6b4aac | ||
|
|
96e18d74c5 | ||
|
|
311093f0fb | ||
|
|
54514d9d21 | ||
|
|
4633d2a179 | ||
|
|
73c2690526 | ||
|
|
25b545200e | ||
|
|
23c35c8886 | ||
|
|
3f801fda2c | ||
|
|
51b9fd8eca | ||
|
|
4dc7a653eb | ||
|
|
ea7c5798ff | ||
|
|
1271459874 | ||
|
|
81098b02a8 | ||
|
|
18d6a51d6b | ||
|
|
9c9c1c301d | ||
|
|
5fb40e0b2e | ||
|
|
9ba51a3fd4 | ||
|
|
05d83ce704 | ||
|
|
36b7a86bf7 | ||
|
|
e6d336f727 | ||
|
|
5ab913f7b5 | ||
|
|
feff61755f | ||
|
|
c98c255ed0 | ||
|
|
446a219cd1 | ||
|
|
94829aea8d | ||
|
|
0c4e21bc6a | ||
|
|
a582b8284d | ||
|
|
bbcbc6d52b | ||
|
|
86cb4fd809 | ||
|
|
b434279422 | ||
|
|
d9900e50a0 | ||
|
|
97f1825a14 | ||
|
|
28e837e8f7 | ||
|
|
f4ea90c2dc | ||
|
|
1d9a28ad64 | ||
|
|
f36a3a2e19 | ||
|
|
a14df55210 | ||
|
|
b3633d5c08 | ||
|
|
5ed84af2e3 | ||
|
|
b52caf0341 | ||
|
|
df4de508e7 | ||
|
|
e37d61f836 | ||
|
|
1230d1f251 | ||
|
|
62b36d4aec | ||
|
|
75c57ad039 | ||
|
|
10673a8d68 | ||
|
|
d488b8ced6 | ||
|
|
0381bdac96 | ||
|
|
87fee33039 | ||
|
|
fe68542547 | ||
|
|
2e1529c755 | ||
|
|
eb20e39b11 | ||
|
|
b771652656 | ||
|
|
e6d720082c | ||
|
|
d9a0d4eee0 | ||
|
|
2a0f2a8b1e | ||
|
|
aedb59ea67 | ||
|
|
4e2abbaa3d | ||
|
|
9db096f046 | ||
|
|
59b2679641 | ||
|
|
b1d26887f0 | ||
|
|
89a748eef6 | ||
|
|
c00230c8af | ||
|
|
2f6d581384 | ||
|
|
468ef34f85 | ||
|
|
7f4005cdcc | ||
|
|
57fdfe26f0 | ||
|
|
88ba4e4562 | ||
|
|
394ba4ff73 | ||
|
|
c0d1efa0ad | ||
|
|
dfc80e6f20 | ||
|
|
187b435eb9 | ||
|
|
1fce8d1183 | ||
|
|
c51d2d3c93 | ||
|
|
4450bebf35 | ||
|
|
b3ce7bd278 | ||
|
|
345a212447 | ||
|
|
83450b7c24 | ||
|
|
b58ec24d81 | ||
|
|
5e799e7e6b | ||
|
|
01ad380822 | ||
|
|
84d2c0fd25 | ||
|
|
87212d3b6e | ||
|
|
4ca6ec1812 | ||
|
|
4452d84097 | ||
|
|
9a81927154 | ||
|
|
daed6eb1e5 | ||
|
|
673669cd25 | ||
|
|
bdd5a77f45 | ||
|
|
92150cb7c8 | ||
|
|
e52becbc40 | ||
|
|
7f3d4aaaa7 | ||
|
|
7748ef2787 | ||
|
|
f2d9895597 | ||
|
|
db4e8588cd | ||
|
|
0c05599282 | ||
|
|
00e1f50458 | ||
|
|
ee89943aa2 | ||
|
|
9dc6b94537 | ||
|
|
e9816f8d3d | ||
|
|
51d1c501cf | ||
|
|
1539a8bc36 | ||
|
|
ffa8c0ce8a | ||
|
|
4762edcba3 | ||
|
|
1bd2da4832 | ||
|
|
b923bfecca | ||
|
|
46e758cc84 | ||
|
|
9165cab1ec | ||
|
|
bda09cc064 | ||
|
|
408cc4dd3f | ||
|
|
c6b636baee | ||
|
|
bc24d1f02c | ||
|
|
62a7cfa188 | ||
|
|
c4fa5cd4d9 | ||
|
|
d40fa27289 | ||
|
|
c789ef555c | ||
|
|
0674edf81b | ||
|
|
9362d126aa | ||
|
|
35797bd393 | ||
|
|
68ff52c432 | ||
|
|
5d2afc065f | ||
|
|
cd8f245483 | ||
|
|
735979c834 | ||
|
|
5cd6996d5e | ||
|
|
ff49996950 | ||
|
|
14417ef14f | ||
|
|
1393ad5143 | ||
|
|
4f262fc041 | ||
|
|
aa639dd78e | ||
|
|
aa9c0b9f6b | ||
|
|
526d6e2a0a | ||
|
|
96746cc592 |
@@ -94,3 +94,4 @@ yarn-debug.log*
|
||||
.vscode
|
||||
.claude/settings.local.json
|
||||
.cursor
|
||||
CLAUDE.local.md
|
||||
|
||||
@@ -179,7 +179,8 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.2.1'
|
||||
gem 'ai-agents', '>= 0.4.3'
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
|
||||
+5
-3
@@ -126,7 +126,7 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.2.1)
|
||||
ai-agents (0.4.3)
|
||||
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.3.1)
|
||||
ruby_llm (1.5.1)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
@@ -729,6 +729,7 @@ 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)
|
||||
@@ -910,7 +911,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.2.1)
|
||||
ai-agents (>= 0.4.3)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
@@ -1004,6 +1005,7 @@ DEPENDENCIES
|
||||
rubocop-rails
|
||||
rubocop-rspec
|
||||
ruby-openai
|
||||
ruby_llm-schema
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.4.0
|
||||
3.4.2
|
||||
|
||||
@@ -47,6 +47,20 @@ 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)
|
||||
@@ -60,12 +74,12 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id)
|
||||
params.permit(:id, :email)
|
||||
end
|
||||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
@@ -88,4 +102,10 @@ 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')
|
||||
|
||||
@@ -136,7 +136,7 @@ export default {
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
||||
class="flex flex-col w-full h-screen min-h-0"
|
||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 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();
|
||||
@@ -0,0 +1,16 @@
|
||||
/* 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,6 +21,14 @@ 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,30 +37,6 @@ 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,6 +20,7 @@ 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']);
|
||||
@@ -98,6 +99,7 @@ watch(
|
||||
:enable-variables="enableVariables"
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
+26
-1
@@ -1,6 +1,9 @@
|
||||
<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';
|
||||
@@ -26,6 +29,20 @@ 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 => {
|
||||
@@ -33,7 +50,10 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const handleDialogConfirm = () => {
|
||||
const handleDialogConfirm = async () => {
|
||||
const isFormCorrect = await v$.value.$validate();
|
||||
if (!isFormCorrect) return;
|
||||
|
||||
emit('addCustomDomain', formState.customDomain);
|
||||
};
|
||||
|
||||
@@ -67,6 +87,11 @@ 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>
|
||||
|
||||
+138
-37
@@ -1,9 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { reactive, computed, ref } 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: {
|
||||
@@ -12,10 +18,20 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm']);
|
||||
const emit = defineEmits(['send', 'close']);
|
||||
|
||||
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) || '';
|
||||
@@ -25,10 +41,34 @@ 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 handleDialogConfirm = () => {
|
||||
emit('confirm');
|
||||
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();
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
@@ -37,42 +77,103 @@ 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"
|
||||
@confirm="handleDialogConfirm"
|
||||
:show-confirm-button="false"
|
||||
@close="resetForm"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+159
-23
@@ -1,6 +1,7 @@
|
||||
<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';
|
||||
@@ -11,11 +12,52 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isFetchingStatus: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updatePortalConfiguration']);
|
||||
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 { t } = useI18n();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const addCustomDomainDialogRef = ref(null);
|
||||
const dnsConfigurationDialogRef = ref(null);
|
||||
@@ -25,6 +67,45 @@ 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,
|
||||
@@ -42,6 +123,17 @@ const closeDNSConfigurationDialog = () => {
|
||||
updatedDomainAddress.value = '';
|
||||
dnsConfigurationDialogRef.value.dialogRef.close();
|
||||
};
|
||||
|
||||
const onClickRefreshSSLStatus = () => {
|
||||
emit('refreshStatus');
|
||||
};
|
||||
|
||||
const onClickSend = email => {
|
||||
emit('sendCnameInstructions', {
|
||||
portalSlug: props.activePortal?.slug,
|
||||
email,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -63,33 +155,76 @@ const closeDNSConfigurationDialog = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col w-full gap-4">
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.LABEL'
|
||||
'HELP_CENTER.PORTAL_SETTINGS.CONFIGURATION_FORM.CUSTOM_DOMAIN.STATUS_DESCRIPTION'
|
||||
)
|
||||
}}
|
||||
</label>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ customDomainAddress }}
|
||||
</span>
|
||||
</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()"
|
||||
/>
|
||||
<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>
|
||||
<Button
|
||||
v-else
|
||||
:label="
|
||||
@@ -112,7 +247,8 @@ const closeDNSConfigurationDialog = () => {
|
||||
<DNSConfigurationDialog
|
||||
ref="dnsConfigurationDialogRef"
|
||||
:custom-domain="updatedDomainAddress || customDomainAddress"
|
||||
@confirm="closeDNSConfigurationDialog"
|
||||
@close="closeDNSConfigurationDialog"
|
||||
@send="onClickSend"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+14
@@ -26,6 +26,8 @@ const emit = defineEmits([
|
||||
'updatePortal',
|
||||
'updatePortalConfiguration',
|
||||
'deletePortal',
|
||||
'refreshStatus',
|
||||
'sendCnameInstructions',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -36,6 +38,7 @@ 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);
|
||||
@@ -53,6 +56,14 @@ const handleUpdatePortalConfiguration = portal => {
|
||||
emit('updatePortalConfiguration', portal);
|
||||
};
|
||||
|
||||
const fetchSSLStatus = () => {
|
||||
emit('refreshStatus');
|
||||
};
|
||||
|
||||
const handleSendCnameInstructions = payload => {
|
||||
emit('sendCnameInstructions', payload);
|
||||
};
|
||||
|
||||
const openConfirmDeletePortalDialog = () => {
|
||||
confirmDeletePortalDialogRef.value.dialogRef.open();
|
||||
};
|
||||
@@ -85,7 +96,10 @@ 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,11 +63,12 @@ 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(() => ({
|
||||
@@ -153,7 +154,7 @@ onBeforeMount(contextMenuActions.close);
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex flex-col w-full gap-2 p-3 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
class="flex flex-col w-full gap-1 p-3 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
@contextmenu="contextMenuActions.open($event)"
|
||||
@click="emit('click')"
|
||||
>
|
||||
@@ -232,7 +233,7 @@ onBeforeMount(contextMenuActions.close);
|
||||
class="flex-shrink-0 text-n-slate-11 size-2.5"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
<span class="text-xs text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<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';
|
||||
@@ -44,7 +45,10 @@ const onClickCancel = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex relative">
|
||||
<div
|
||||
v-on-click-outside="() => togglePopover(false)"
|
||||
class="inline-flex relative"
|
||||
>
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
sm
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<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,7 +73,6 @@ 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">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<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>
|
||||
@@ -0,0 +1,218 @@
|
||||
<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>
|
||||
@@ -0,0 +1,37 @@
|
||||
<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>
|
||||
@@ -0,0 +1,54 @@
|
||||
<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,6 +2,7 @@
|
||||
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';
|
||||
@@ -178,11 +179,11 @@ defineExpose({ validate });
|
||||
disable-search
|
||||
:options="booleanOptions"
|
||||
/>
|
||||
<input
|
||||
<Input
|
||||
v-else
|
||||
v-model="values"
|
||||
:type="inputType === 'date' ? 'date' : 'text'"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base"
|
||||
class="[&>input]:h-8 [&>input]:py-1.5 [&>input]:outline-offset-0"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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({
|
||||
@@ -109,16 +110,13 @@ const outsideClickHandler = [
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isSegmentView">
|
||||
<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
|
||||
<div class="pb-6 border-b border-n-weak">
|
||||
<Input
|
||||
v-model="segmentNameLocal"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
||||
:label="$t('CONTACTS_LAYOUT.FILTER.SEGMENT.LABEL')"
|
||||
:placeholder="t('CONTACTS_LAYOUT.FILTER.SEGMENT.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
|
||||
@@ -9,6 +9,7 @@ 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({
|
||||
@@ -110,16 +111,13 @@ const outsideClickHandler = [
|
||||
{{ filterModalHeaderTitle }}
|
||||
</h3>
|
||||
<div v-if="props.isFolderView">
|
||||
<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
|
||||
<div class="border-b border-n-weak pb-6">
|
||||
<Input
|
||||
v-model="folderNameLocal"
|
||||
class="py-1.5 px-3 text-n-slate-12 bg-n-alpha-1 text-sm rounded-lg reset-base w-full"
|
||||
:label="t('FILTER.FOLDER_LABEL')"
|
||||
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="grid gap-4 list-none">
|
||||
<template v-for="(filter, index) in filters" :key="filter.id">
|
||||
|
||||
@@ -6,10 +6,12 @@ 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,
|
||||
@@ -103,20 +105,13 @@ export default {
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.TITLE') }}
|
||||
</h3>
|
||||
<form class="w-full grid gap-6" @submit.prevent="saveCustomViews">
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
<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') }}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<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,6 +8,7 @@ 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';
|
||||
@@ -17,10 +18,18 @@ 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();
|
||||
@@ -77,6 +86,11 @@ 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',
|
||||
@@ -488,7 +502,19 @@ const menuItems = computed(() => {
|
||||
|
||||
<template>
|
||||
<aside
|
||||
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"
|
||||
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,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<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,7 +4,12 @@ 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,
|
||||
@@ -13,12 +18,20 @@ 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'](
|
||||
@@ -32,7 +45,6 @@ const isFeatureEnabledonAccount = useMapGetter(
|
||||
);
|
||||
|
||||
const selectedAssistantId = ref(null);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const activeAssistant = computed(() => {
|
||||
const preferredId = uiSettings.value.preferred_captain_assistant_id;
|
||||
@@ -55,6 +67,15 @@ 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({
|
||||
@@ -63,6 +84,9 @@ const setAssistant = async assistant => {
|
||||
};
|
||||
|
||||
const shouldShowCopilotPanel = computed(() => {
|
||||
if (!isEnterprise) {
|
||||
return false;
|
||||
}
|
||||
const isCaptainEnabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
@@ -94,14 +118,23 @@ const sendMessage = async message => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
if (isEnterprise) {
|
||||
store.dispatch('captainAssistants/get');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="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"
|
||||
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,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Copilot
|
||||
:messages="messages"
|
||||
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -72,6 +73,7 @@ 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: '' },
|
||||
@@ -89,6 +91,7 @@ const emit = defineEmits([
|
||||
'toggleUserMention',
|
||||
'toggleCannedMenu',
|
||||
'toggleVariablesMenu',
|
||||
'toggleToolsMenu',
|
||||
'clearSelection',
|
||||
'blur',
|
||||
'focus',
|
||||
@@ -140,7 +143,9 @@ 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('');
|
||||
@@ -216,11 +221,17 @@ const plugins = computed(() => {
|
||||
}
|
||||
|
||||
return [
|
||||
createSuggestionPlugin({
|
||||
trigger: '@',
|
||||
showMenu: showToolsMenu,
|
||||
searchTerm: toolSearchKey,
|
||||
isAllowed: () => props.enableCaptainTools,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '@',
|
||||
showMenu: showUserMentions,
|
||||
searchTerm: mentionSearchKey,
|
||||
isAllowed: () => props.isPrivate,
|
||||
isAllowed: () => props.isPrivate || !props.enableCaptainTools,
|
||||
}),
|
||||
createSuggestionPlugin({
|
||||
trigger: '/',
|
||||
@@ -262,6 +273,9 @@ 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;
|
||||
@@ -538,6 +552,7 @@ 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]);
|
||||
@@ -699,6 +714,11 @@ 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"
|
||||
@@ -812,6 +832,10 @@ 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,6 +68,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowedContextMenuOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'contextMenuToggle',
|
||||
@@ -151,11 +155,9 @@ export default {
|
||||
hasSlaPolicyId() {
|
||||
return this.chat?.sla_policy_id;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onCardClick(e) {
|
||||
conversationPath() {
|
||||
const { activeInbox, chat } = this;
|
||||
const path = frontendURL(
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: this.accountId,
|
||||
activeInbox,
|
||||
@@ -166,18 +168,26 @@ 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 nofollow'
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.isActiveChat) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already active
|
||||
if (this.isActiveChat) return;
|
||||
|
||||
router.push({ path });
|
||||
},
|
||||
@@ -359,6 +369,8 @@ 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"
|
||||
@@ -367,6 +379,7 @@ export default {
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
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: {
|
||||
@@ -10,7 +13,8 @@ defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const { uiSettings } = useUISettings();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const activeTab = computed(() => {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
|
||||
@@ -20,11 +24,31 @@ 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
|
||||
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"
|
||||
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,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-1 overflow-auto">
|
||||
<ContactPanel
|
||||
|
||||
@@ -372,6 +372,7 @@ export default {
|
||||
const variables = getMessageVariables({
|
||||
conversation: this.currentChat,
|
||||
contact: this.currentContact,
|
||||
inbox: this.inbox,
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<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,5 +1,8 @@
|
||||
<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,
|
||||
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -37,6 +53,14 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
conversationUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
allowedOptions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'updateConversation',
|
||||
@@ -47,6 +71,7 @@ export default {
|
||||
'assignTeam',
|
||||
'assignLabel',
|
||||
'deleteConversation',
|
||||
'close',
|
||||
],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
@@ -56,6 +81,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
MENU,
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
readOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||
@@ -63,7 +89,7 @@ export default {
|
||||
},
|
||||
unreadOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
|
||||
icon: 'mail',
|
||||
icon: 'mail-unread',
|
||||
},
|
||||
statusMenuConfig: [
|
||||
{
|
||||
@@ -88,7 +114,7 @@ export default {
|
||||
icon: 'snooze',
|
||||
},
|
||||
priorityConfig: {
|
||||
key: 'priority',
|
||||
key: MENU.PRIORITY,
|
||||
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
|
||||
icon: 'warning',
|
||||
options: [
|
||||
@@ -115,25 +141,35 @@ export default {
|
||||
].filter(item => item.key !== this.priority),
|
||||
},
|
||||
labelMenuConfig: {
|
||||
key: 'label',
|
||||
key: MENU.LABEL,
|
||||
icon: 'tag',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
|
||||
},
|
||||
agentMenuConfig: {
|
||||
key: 'agent',
|
||||
key: MENU.AGENT,
|
||||
icon: 'person-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
|
||||
},
|
||||
teamMenuConfig: {
|
||||
key: 'team',
|
||||
key: MENU.TEAM,
|
||||
icon: 'people-team-add',
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
|
||||
},
|
||||
deleteOption: {
|
||||
key: 'delete',
|
||||
key: MENU.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: {
|
||||
@@ -180,6 +216,10 @@ 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);
|
||||
},
|
||||
@@ -194,6 +234,24 @@ 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.
|
||||
@@ -217,83 +275,114 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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])">
|
||||
<MenuItem
|
||||
v-if="show(option.key)"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
v-if="!hasUnreadMessages"
|
||||
:option="unreadOption"
|
||||
variant="icon"
|
||||
@click.stop="toggleStatus(option.key, null)"
|
||||
@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>
|
||||
<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>
|
||||
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
|
||||
<template v-for="option in statusMenuConfig">
|
||||
<MenuItem
|
||||
v-for="agent in assignableAgents"
|
||||
:key="agent.id"
|
||||
:option="generateMenuLabelConfig(agent, 'agent')"
|
||||
variant="agent"
|
||||
@click.stop="$emit('assignAgent', agent)"
|
||||
v-if="show(option.key) && isAllowed([MENU.STATUS])"
|
||||
:key="option.key"
|
||||
:option="option"
|
||||
variant="icon"
|
||||
@click.stop="toggleStatus(option.key, null)"
|
||||
/>
|
||||
</template>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
: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)"
|
||||
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
|
||||
:option="snoozeOption"
|
||||
variant="icon"
|
||||
@click.stop="snoozeConversation()"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<template v-if="isAdmin">
|
||||
<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
|
||||
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"
|
||||
/>
|
||||
<MenuItem
|
||||
v-if="isAllowed([MENU.COPY_LINK])"
|
||||
:option="copyLinkOption"
|
||||
variant="icon"
|
||||
@click.stop="copyConversationLink"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
|
||||
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
|
||||
<MenuItem
|
||||
:option="deleteOption"
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
defineProps({
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
props: {
|
||||
option: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -30,12 +26,12 @@ export default {
|
||||
class="label-pill flex-shrink-0"
|
||||
:style="{ backgroundColor: option.color }"
|
||||
/>
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="variant === 'agent'"
|
||||
:username="option.label"
|
||||
:name="option.label"
|
||||
:src="option.thumbnail"
|
||||
:status="option.status"
|
||||
size="20px"
|
||||
:status="option.status === 'online' ? option.status : null"
|
||||
:size="20"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<p class="menu-label truncate min-w-0 flex-1">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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);
|
||||
@@ -33,7 +35,9 @@ export function useCaptain() {
|
||||
});
|
||||
|
||||
const fetchLimits = () => {
|
||||
store.dispatch('accounts/limits');
|
||||
if (isEnterprise) {
|
||||
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: 1200,
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||
|
||||
@@ -6,6 +6,7 @@ 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,6 +319,12 @@ 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;
|
||||
}
|
||||
@@ -355,6 +361,11 @@ const nodeCreators = {
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
tool: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'tool', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -144,6 +144,9 @@
|
||||
"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,6 +157,12 @@
|
||||
"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": {
|
||||
@@ -747,9 +753,15 @@
|
||||
"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 custom domain",
|
||||
"EDIT_BUTTON": "Edit",
|
||||
"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",
|
||||
@@ -757,13 +769,20 @@
|
||||
"EDIT_CONFIRM_BUTTON_LABEL": "Update domain",
|
||||
"LABEL": "Custom domain",
|
||||
"PLACEHOLDER": "Portal custom domain",
|
||||
"ERROR": "Custom domain is required"
|
||||
"ERROR": "Custom domain is required",
|
||||
"FORMAT_ERROR": "Please enter a valid domain URL e.g. docs.yourdomain.com"
|
||||
},
|
||||
"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",
|
||||
"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!"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DELETE_PORTAL": {
|
||||
|
||||
@@ -554,6 +554,7 @@
|
||||
"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",
|
||||
@@ -601,6 +602,7 @@
|
||||
"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",
|
||||
@@ -615,6 +617,73 @@
|
||||
"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": "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.",
|
||||
"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'.",
|
||||
"LEARN_MORE": "Learn about agent bots",
|
||||
"GLOBAL_BOT": "System bot",
|
||||
"GLOBAL_BOT_BADGE": "System",
|
||||
@@ -30,10 +30,10 @@
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"404": "No bots found. You can create a bot by clicking the 'Add Bot' button.",
|
||||
"404": "Nie znaleziono botów. Możesz utworzyć bota klikając przycisk 'Dodaj bota'.",
|
||||
"LOADING": "Pobieranie botów...",
|
||||
"TABLE_HEADER": {
|
||||
"DETAILS": "Bot Details",
|
||||
"DETAILS": "Szczegóły bota",
|
||||
"URL": "Adres URL webhooka"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
|
||||
@@ -18,6 +19,8 @@ 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,
|
||||
@@ -27,17 +30,20 @@ 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() {
|
||||
@@ -45,10 +51,13 @@ export default {
|
||||
showAccountModal: false,
|
||||
showCreateAccountModal: false,
|
||||
showShortcutModal: false,
|
||||
displayLayoutType: '',
|
||||
isMobileSidebarOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isSmallScreen() {
|
||||
return this.windowWidth < wootConstants.SMALL_SCREEN_BREAKPOINT;
|
||||
},
|
||||
showUpgradePage() {
|
||||
return this.upgradePageRef?.shouldShowUpgradePage;
|
||||
},
|
||||
@@ -66,54 +75,30 @@ export default {
|
||||
} = this.uiSettings;
|
||||
return conversationDisplayType;
|
||||
},
|
||||
previouslyUsedSidebarView() {
|
||||
const { previously_used_sidebar_view: showSecondarySidebar } =
|
||||
this.uiSettings;
|
||||
return showSecondarySidebar;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
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,
|
||||
});
|
||||
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,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.handleResize();
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
},
|
||||
|
||||
methods: {
|
||||
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);
|
||||
toggleMobileSidebar() {
|
||||
this.isMobileSidebarOpen = !this.isMobileSidebarOpen;
|
||||
},
|
||||
closeMobileSidebar() {
|
||||
this.isMobileSidebarOpen = false;
|
||||
},
|
||||
openCreateAccountModal() {
|
||||
this.showAccountModal = false;
|
||||
@@ -136,23 +121,35 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap app-wrapper text-n-slate-12">
|
||||
<div class="flex flex-grow overflow-hidden 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 min-h-0 px-0 overflow-hidden">
|
||||
|
||||
<main class="flex flex-1 h-full w-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,6 +266,11 @@ 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,6 +284,13 @@ 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"
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<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 it’s 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 2–3 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 2–3 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,6 +7,7 @@ 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';
|
||||
|
||||
@@ -67,6 +68,21 @@ 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,6 +60,8 @@ 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"
|
||||
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
|
||||
@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 md:px-0 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 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">
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
|
||||
<div class="flex w-full h-full min-h-0">
|
||||
<section
|
||||
v-if="isHelpCenterEnabled"
|
||||
class="flex flex-1 h-full px-0 overflow-hidden bg-n-background"
|
||||
|
||||
+43
-1
@@ -4,12 +4,16 @@ 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();
|
||||
|
||||
@@ -24,6 +28,15 @@ 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([
|
||||
@@ -106,8 +119,35 @@ 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 = 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 handleDeletePortal = deletePortal;
|
||||
</script>
|
||||
|
||||
@@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal;
|
||||
@update-portal="handleUpdatePortal"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
@delete-portal="handleDeletePortal"
|
||||
@refresh-status="fetchSSLStatus"
|
||||
@send-cname-instructions="handleSendCnameInstructions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,221 +1,236 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<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';
|
||||
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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxCard,
|
||||
InboxListHeader,
|
||||
IntersectionObserver,
|
||||
CmdBarConversationSnooze,
|
||||
Spinner,
|
||||
},
|
||||
setup() {
|
||||
const { uiSettings } = useUISettings();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
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;
|
||||
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);
|
||||
|
||||
if (this.$route.params.notification_id !== id) {
|
||||
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
const infiniteLoaderOptions = computed(() => ({
|
||||
root: notificationList.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
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
|
||||
});
|
||||
const meta = useMapGetter('notifications/getMeta');
|
||||
const uiFlags = useMapGetter('notifications/getUIFlags');
|
||||
const records = useMapGetter('notifications/getFilteredNotificationsV4');
|
||||
const inboxById = useMapGetter('inboxes/getInboxById');
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
params: { inboxId, notification_id: id },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
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);
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(currentConversationId, () => {
|
||||
nextTick(scrollActiveIntoView);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
scrollActiveIntoView();
|
||||
setSavedFilter();
|
||||
fetchNotifications();
|
||||
});
|
||||
</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-[400px] lg:max-w-[400px] ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="!currentNotificationId ? 'flex' : 'hidden xl:flex'"
|
||||
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'"
|
||||
>
|
||||
<InboxListHeader
|
||||
:is-context-menu-open="isInboxContextMenuOpen"
|
||||
@@ -224,17 +239,17 @@ export default {
|
||||
/>
|
||||
<div
|
||||
ref="notificationList"
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<InboxCard
|
||||
v-for="notificationItem in notificationsV4"
|
||||
v-for="notificationItem in notifications"
|
||||
:key="notificationItem.id"
|
||||
:inbox-item="notificationItem"
|
||||
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
:class="
|
||||
currentNotificationId === notificationItem.id
|
||||
? 'bg-n-alpha-1 dark:bg-n-alpha-3 click-animation rounded-xl active'
|
||||
currentConversationId === notificationItem.primaryActor?.id
|
||||
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
|
||||
: ''
|
||||
"
|
||||
@mark-notification-as-read="markNotificationAsRead"
|
||||
@@ -264,23 +279,3 @@ export default {
|
||||
<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,183 +1,191 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
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 { 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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxItemHeader,
|
||||
InboxEmptyState,
|
||||
ConversationBox,
|
||||
Spinner,
|
||||
},
|
||||
setup() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
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';
|
||||
|
||||
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;
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
const isConversationLoading = ref(false);
|
||||
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount,
|
||||
});
|
||||
const notification = useMapGetter('notifications/getFilteredNotifications');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationById = useMapGetter('getConversationById');
|
||||
const uiFlags = useMapGetter('notifications/getUIFlags');
|
||||
const meta = useMapGetter('notifications/getMeta');
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
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();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await store.dispatch('agents/get');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full xl:w-[calc(100%-400px)]">
|
||||
<div class="h-full w-full flex-1">
|
||||
<div v-if="showEmptyState" class="flex w-full h-full">
|
||||
<InboxEmptyState
|
||||
:empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
|
||||
@@ -193,19 +201,24 @@ export default {
|
||||
/>
|
||||
<div
|
||||
v-if="isConversationLoading"
|
||||
class="flex items-center h-[calc(100%-56px)] my-4 justify-center bg-n-solid-1"
|
||||
class="flex items-center flex-1 my-4 justify-center bg-n-solid-1"
|
||||
>
|
||||
<Spinner class="text-n-brand" />
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import MenuItem from './MenuItem.vue';
|
||||
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
defineProps({
|
||||
contextMenuPosition: {
|
||||
type: Object,
|
||||
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();
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'selectAction']);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onMenuItemClick = key => {
|
||||
emit('selectAction', key);
|
||||
handleClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,12 +32,14 @@ export default {
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
|
||||
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
|
||||
>
|
||||
<MenuItem
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
:label="item.label"
|
||||
:option="item"
|
||||
variant="icon"
|
||||
class="!w-48"
|
||||
@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 ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
|
||||
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"
|
||||
>
|
||||
<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-14 px-4 mb-2">
|
||||
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h1 class="min-w-0 text-lg font-medium truncate text-n-slate-12">
|
||||
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
|
||||
{{ $t('INBOX.LIST.TITLE') }}
|
||||
</h1>
|
||||
<div class="relative">
|
||||
|
||||
@@ -21,7 +21,7 @@ export const routes = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':notification_id',
|
||||
path: ':type/:id',
|
||||
name: 'inbox_view_conversation',
|
||||
component: InboxDetailView,
|
||||
meta: {
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
<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: '',
|
||||
@@ -67,14 +56,5 @@ 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,13 +2,10 @@
|
||||
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 },
|
||||
@@ -16,16 +13,8 @@ const props = defineProps({
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showNewButton = computed(
|
||||
() => props.newButtonRoutes.length && !props.showBackButton
|
||||
);
|
||||
|
||||
const showSettingsHeader = computed(
|
||||
() =>
|
||||
props.headerTitle ||
|
||||
props.icon ||
|
||||
props.showBackButton ||
|
||||
showNewButton.value
|
||||
() => props.headerTitle || props.icon || props.showBackButton
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -37,13 +26,10 @@ 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,9 +42,7 @@ 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,9 +41,7 @@ export default {
|
||||
props: () => {
|
||||
return {
|
||||
headerTitle: 'TEAMS_SETTINGS.HEADER',
|
||||
headerButtonText: 'TEAMS_SETTINGS.NEW_TEAM',
|
||||
icon: 'people-team',
|
||||
newButtonRoutes: ['settings_teams_new'],
|
||||
showBackButton: true,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ 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';
|
||||
@@ -22,6 +23,7 @@ const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { accountId, currentAccount } = useAccount();
|
||||
const { isEnterprise } = useConfig();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||
@@ -100,48 +102,54 @@ const routeToBilling = () => {
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => fetchLimits());
|
||||
onMounted(() => {
|
||||
if (isEnterprise) {
|
||||
fetchLimits();
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({ shouldShowUpgradePage });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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
|
||||
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>
|
||||
</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>
|
||||
</template>
|
||||
<template v-else />
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
@@ -53,6 +53,8 @@ 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 = [];
|
||||
|
||||
@@ -111,6 +113,8 @@ export default createStore({
|
||||
captainBulkActions,
|
||||
copilotThreads,
|
||||
copilotMessages,
|
||||
captainScenarios,
|
||||
captainTools,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@@ -116,4 +116,24 @@ 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,6 +8,7 @@ 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,6 +6,7 @@ export const defaultPortalFlags = {
|
||||
isFetching: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
isFetchingSSLStatus: false,
|
||||
};
|
||||
|
||||
const state = {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 = {
|
||||
@@ -110,4 +111,18 @@ 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,6 +135,36 @@ 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,6 +89,25 @@ 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,6 +157,14 @@ 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 = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
include BillingHelper
|
||||
|
||||
def perform
|
||||
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
|
||||
@@ -11,6 +12,13 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
|
||||
private
|
||||
|
||||
def should_fetch_emails?(inbox)
|
||||
inbox.channel.imap_enabled && !inbox.account.suspended?
|
||||
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
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,10 +37,11 @@ class HookListener < BaseListener
|
||||
private
|
||||
|
||||
def execute_hooks(event, message)
|
||||
message.account.hooks.each do |hook|
|
||||
message.account.hooks.find_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
|
||||
@@ -48,7 +49,24 @@ 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,16 +4,17 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
@@ -26,6 +26,14 @@ class PortalPolicy < ApplicationPolicy
|
||||
def logo?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def send_instructions?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def ssl_status?
|
||||
@account.users.include?(@user)
|
||||
end
|
||||
end
|
||||
|
||||
PortalPolicy.prepend_mod_with('PortalPolicy')
|
||||
|
||||
@@ -157,6 +157,10 @@ class MailPresenter < SimpleDelegator
|
||||
auto_submitted? || x_auto_reply?
|
||||
end
|
||||
|
||||
def bounced?
|
||||
@mail.bounced? || @mail['X-Failed-Recipients'].try(:value).present?
|
||||
end
|
||||
|
||||
def notification_email_from_chatwoot?
|
||||
# notification emails are send via mailer sender email address. so it should match
|
||||
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
|
||||
|
||||
@@ -156,11 +156,18 @@ class Whatsapp::IncomingMessageBaseService
|
||||
phones = contact[:phones]
|
||||
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
|
||||
|
||||
name_info = contact['name'] || {}
|
||||
contact_meta = {
|
||||
firstName: name_info['first_name'],
|
||||
lastName: name_info['last_name']
|
||||
}.compact
|
||||
|
||||
phones.each do |phone|
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_content_type(message_type),
|
||||
fallback_title: phone[:phone].to_s
|
||||
fallback_title: phone[:phone].to_s,
|
||||
meta: contact_meta
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,3 +34,10 @@ json.meta do
|
||||
json.categories_count portal.categories.try(:size)
|
||||
json.default_locale portal.default_locale
|
||||
end
|
||||
|
||||
if portal.ssl_settings.present?
|
||||
json.ssl_settings do
|
||||
json.status portal.ssl_settings['cf_status']
|
||||
json.verification_errors portal.ssl_settings['cf_verification_errors']
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<tr>
|
||||
<td>
|
||||
<p>Hello there,</p>
|
||||
<p>To complete the setup of your help center, you'll need to update the DNS settings for your custom domain: <strong>{{ cname_record | split: ' ' | first }}</strong>.</p>
|
||||
<p>Please add the following CNAME record to your DNS provider's configuration:</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<p><strong>{{ cname_record }}</strong></p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
<p>Step-by-step Instructions:</p>
|
||||
|
||||
<ol>
|
||||
<li>Log in to your DNS provider’s dashboard</li>
|
||||
<li>Go to the DNS management section</li>
|
||||
<li>Create a new CNAME record using the information above</li>
|
||||
<li>Save the changes and allow up to 24 hours for the DNS to propagate</li>
|
||||
</ol>
|
||||
|
||||
<p>Once the DNS record is live, your custom domain will automatically be secured with an SSL certificate.</p>
|
||||
|
||||
<p>If you have any questions or need help, feel free to reach out to our support team—we’re here to assist you.</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -24,3 +24,18 @@
|
||||
title: 'Add Label to Conversation'
|
||||
description: 'Add a label to a conversation'
|
||||
icon: 'tag'
|
||||
|
||||
- id: faq_lookup
|
||||
title: 'FAQ Lookup'
|
||||
description: 'Search FAQ responses using semantic similarity'
|
||||
icon: 'search'
|
||||
|
||||
- id: handoff
|
||||
title: 'Handoff to Human'
|
||||
description: 'Hand off the conversation to a human agent'
|
||||
icon: 'user-switch'
|
||||
|
||||
- id: better_stack_status
|
||||
title: 'Better Stack Status'
|
||||
description: 'Check the status of Better Stack services'
|
||||
icon: 'server'
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'agents'
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4.1-mini'
|
||||
|
||||
if api_key.present?
|
||||
Agents.configure do |config|
|
||||
config.openai_api_key = api_key
|
||||
config.default_model = model
|
||||
config.debug = false
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to configure AI Agents SDK: #{e.message}"
|
||||
end
|
||||
@@ -358,3 +358,12 @@ en:
|
||||
|
||||
Transcript:
|
||||
%{format_messages}
|
||||
portals:
|
||||
send_instructions:
|
||||
email_required: 'Email is required'
|
||||
invalid_email_format: 'Invalid email format'
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
instructions_sent_successfully: 'Instructions sent successfully'
|
||||
subject: 'Finish setting up %{custom_domain}'
|
||||
ssl_status:
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
|
||||
@@ -290,6 +290,8 @@ Rails.application.routes.draw do
|
||||
member do
|
||||
patch :archive
|
||||
delete :logo
|
||||
post :send_instructions
|
||||
get :ssl_status
|
||||
end
|
||||
resources :categories
|
||||
resources :articles do
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Description: Install and manage a Chatwoot installation.
|
||||
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
|
||||
# Script Version: 3.4.0
|
||||
# Script Version: 3.4.2
|
||||
# Run this script as root
|
||||
|
||||
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
||||
@@ -990,7 +990,7 @@ EOF
|
||||
# Check if CW_VERSION is 4.0 or above
|
||||
if [[ "$(printf '%s\n' "$CW_VERSION" "4.0" | sort -V | head -n 1)" == "4.0" ]]; then
|
||||
echo "Chatwoot v4.0 and above requires pgvector support in PostgreSQL."
|
||||
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [Y/n]: " user_input
|
||||
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [y/N]: " user_input
|
||||
user_input=${user_input:-Y}
|
||||
if [[ "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then
|
||||
echo "Proceeding with the upgrade..."
|
||||
@@ -1005,7 +1005,8 @@ EOF
|
||||
upgrade_redis
|
||||
upgrade_node
|
||||
get_pnpm
|
||||
sudo -i -u chatwoot << "EOF"
|
||||
|
||||
sudo -i -u chatwoot << EOF
|
||||
|
||||
# Navigate to the Chatwoot directory
|
||||
cd chatwoot
|
||||
@@ -1016,9 +1017,9 @@ EOF
|
||||
|
||||
# Ensure the ruby version is upto date
|
||||
# Parse the latest ruby version
|
||||
latest_ruby_version="$(cat '.ruby-version')"
|
||||
rvm install "ruby-$latest_ruby_version"
|
||||
rvm use "$latest_ruby_version" --default
|
||||
latest_ruby_version="\$(cat '.ruby-version')"
|
||||
rvm install "ruby-\$latest_ruby_version"
|
||||
rvm use "\$latest_ruby_version" --default
|
||||
|
||||
# Update dependencies
|
||||
bundle
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
module Enterprise::Api::V1::Accounts::PortalsController
|
||||
def ssl_status
|
||||
return render_could_not_create_error(I18n.t('portals.ssl_status.custom_domain_not_configured')) if @portal.custom_domain.blank?
|
||||
|
||||
result = Cloudflare::CheckCustomHostnameService.new(portal: @portal).perform
|
||||
|
||||
return render_could_not_create_error(result[:errors]) if result[:errors].present?
|
||||
|
||||
ssl_settings = @portal.ssl_settings || {}
|
||||
render json: {
|
||||
status: ssl_settings['cf_status'],
|
||||
verification_errors: ssl_settings['cf_verification_errors']
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -26,9 +26,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
@response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
end
|
||||
|
||||
return process_action('handoff') if handoff_requested?
|
||||
|
||||
@@ -104,4 +110,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
def log_error(error)
|
||||
ChatwootExceptionTracker.new(error, account: account).capture_exception
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
return account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
class Captain::Assistant < ApplicationRecord
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
self.table_name = 'captain_assistants'
|
||||
|
||||
@@ -35,6 +36,8 @@ class Captain::Assistant < ApplicationRecord
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
|
||||
|
||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||
|
||||
validates :name, presence: true
|
||||
validates :description, presence: true
|
||||
validates :account_id, presence: true
|
||||
@@ -71,6 +74,33 @@ class Captain::Assistant < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
name
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[
|
||||
self.class.resolve_tool_class('faq_lookup').new(self),
|
||||
self.class.resolve_tool_class('handoff').new(self)
|
||||
]
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
name: name,
|
||||
description: description,
|
||||
product_name: config['product_name'] || 'this product',
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
key: scenario.title.parameterize.underscore,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
response_guidelines: response_guidelines || [],
|
||||
guardrails: guardrails || []
|
||||
}
|
||||
end
|
||||
|
||||
def default_avatar_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg"
|
||||
end
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
@@ -37,10 +38,43 @@ class Captain::Scenario < ApplicationRecord
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
instructions: resolved_instructions,
|
||||
tools: resolved_tools
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".titleize
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
|
||||
end
|
||||
|
||||
def resolved_instructions
|
||||
instruction.gsub(TOOL_REFERENCE_REGEX) do |match|
|
||||
"#{match} tool "
|
||||
end
|
||||
end
|
||||
|
||||
def resolved_tools
|
||||
return [] if tools.blank?
|
||||
|
||||
available_tools = self.class.available_agent_tools
|
||||
tools.filter_map do |tool_id|
|
||||
available_tools.find { |tool| tool[:id] == tool_id }
|
||||
end
|
||||
end
|
||||
|
||||
# Validates that all tool references in the instruction are valid.
|
||||
# Parses the instruction for tool references and checks if they exist
|
||||
# in the available tools configuration.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
module Concerns::Agentable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def agent
|
||||
Agents::Agent.new(
|
||||
name: agent_name,
|
||||
instructions: ->(context) { agent_instructions(context) },
|
||||
tools: agent_tools,
|
||||
model: agent_model,
|
||||
temperature: temperature || 0.7,
|
||||
response_schema: agent_response_schema
|
||||
)
|
||||
end
|
||||
|
||||
def agent_instructions(context = nil)
|
||||
enhanced_context = prompt_context
|
||||
|
||||
if context
|
||||
state = context.context[:state] || {}
|
||||
conversation_data = state[:conversation] || {}
|
||||
contact_data = state[:contact] || {}
|
||||
enhanced_context = enhanced_context.merge(
|
||||
conversation: conversation_data,
|
||||
contact: contact_data
|
||||
)
|
||||
end
|
||||
|
||||
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
raise NotImplementedError, "#{self.class} must implement agent_name"
|
||||
end
|
||||
|
||||
def template_name
|
||||
self.class.name.demodulize.underscore
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
[] # Default implementation, override if needed
|
||||
end
|
||||
|
||||
def agent_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4.1-mini'
|
||||
end
|
||||
|
||||
def agent_response_schema
|
||||
Captain::ResponseSchema
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
raise NotImplementedError, "#{self.class} must implement prompt_context"
|
||||
end
|
||||
end
|
||||
@@ -68,8 +68,16 @@ module Enterprise::Concerns::Article
|
||||
headers = { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{ENV.fetch('OPENAI_API_KEY', nil)}" }
|
||||
body = { model: 'gpt-4o', messages: messages, response_format: { type: 'json_object' } }.to_json
|
||||
Rails.logger.info "Requesting Chat GPT with body: #{body}"
|
||||
response = HTTParty.post('https://api.openai.com/v1/chat/completions', headers: headers, body: body)
|
||||
response = HTTParty.post(openai_api_url, headers: headers, body: body)
|
||||
Rails.logger.info "Chat GPT response: #{response.body}"
|
||||
JSON.parse(response.parsed_response['choices'][0]['message']['content'])['search_terms']
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def openai_api_url
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
|
||||
endpoint = endpoint.chomp('/')
|
||||
"#{endpoint}/v1/chat/completions"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Assistant::AgentRunnerService
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
agents = build_and_wire_agents
|
||||
context = build_context(message_history)
|
||||
message_to_process = extract_last_user_message(message_history)
|
||||
runner = Agents::Runner.with_agents(*agents)
|
||||
runner = add_callbacks_to_runner(runner) if @callbacks.any?
|
||||
result = runner.run(message_to_process, context: context)
|
||||
|
||||
process_agent_result(result)
|
||||
rescue StandardError => e
|
||||
# when running the agent runner service in a rake task, the conversation might not have an account associated
|
||||
# for regular production usage, it will run just fine
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
|
||||
error_response(e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_context(message_history)
|
||||
conversation_history = message_history.map do |msg|
|
||||
content = extract_text_from_content(msg[:content])
|
||||
|
||||
{
|
||||
role: msg[:role].to_sym,
|
||||
content: content,
|
||||
agent_name: msg[:agent_name]
|
||||
}
|
||||
end
|
||||
|
||||
{
|
||||
conversation_history: conversation_history,
|
||||
state: build_state
|
||||
}
|
||||
end
|
||||
|
||||
def extract_last_user_message(message_history)
|
||||
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
|
||||
|
||||
extract_text_from_content(last_user_msg[:content])
|
||||
end
|
||||
|
||||
def extract_text_from_content(content)
|
||||
# Handle structured output from agents
|
||||
return content[:response] || content['response'] || content.to_s if content.is_a?(Hash)
|
||||
|
||||
return content unless content.is_a?(Array)
|
||||
|
||||
text_parts = content.select { |part| part[:type] == 'text' }.pluck(:text)
|
||||
text_parts.join(' ')
|
||||
end
|
||||
|
||||
# Response formatting methods
|
||||
def process_agent_result(result)
|
||||
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
|
||||
format_response(result.output)
|
||||
end
|
||||
|
||||
def format_response(output)
|
||||
return output.with_indifferent_access if output.is_a?(Hash)
|
||||
|
||||
# Fallback for backwards compatibility
|
||||
{
|
||||
'response' => output.to_s,
|
||||
'reasoning' => 'Processed by agent'
|
||||
}
|
||||
end
|
||||
|
||||
def error_response(error_message)
|
||||
{
|
||||
'response' => 'conversation_handoff',
|
||||
'reasoning' => "Error occurred: #{error_message}"
|
||||
}
|
||||
end
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
|
||||
if @conversation
|
||||
state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any?
|
||||
scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) }
|
||||
|
||||
[assistant_agent] + scenario_agents
|
||||
end
|
||||
|
||||
def add_callbacks_to_runner(runner)
|
||||
if @callbacks[:on_agent_thinking]
|
||||
runner = runner.on_agent_thinking do |*args|
|
||||
@callbacks[:on_agent_thinking].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if @callbacks[:on_tool_start]
|
||||
runner = runner.on_tool_start do |*args|
|
||||
@callbacks[:on_tool_start].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if @callbacks[:on_tool_complete]
|
||||
runner = runner.on_tool_complete do |*args|
|
||||
@callbacks[:on_tool_complete].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
if @callbacks[:on_agent_handoff]
|
||||
runner = runner.on_agent_handoff do |*args|
|
||||
@callbacks[:on_agent_handoff].call(*args)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
runner
|
||||
end
|
||||
end
|
||||
@@ -17,4 +17,25 @@ class Cloudflare::BaseCloudflareZoneService
|
||||
def zone_id
|
||||
InstallationConfig.find_by(name: 'CLOUDFLARE_ZONE_ID')&.value
|
||||
end
|
||||
|
||||
def update_portal_ssl_settings(portal, data)
|
||||
verification_record = data['ownership_verification_http']
|
||||
ssl_record = data['ssl']
|
||||
verification_errors = data['verification_errors']&.first || ''
|
||||
|
||||
# Start with existing settings to preserve verification data if it exists
|
||||
ssl_settings = portal.ssl_settings || {}
|
||||
|
||||
# Only update verification fields if they exist in the response (during initial setup)
|
||||
if verification_record.present?
|
||||
ssl_settings['cf_verification_id'] = verification_record['http_url'].split('/').last
|
||||
ssl_settings['cf_verification_body'] = verification_record['http_body']
|
||||
end
|
||||
|
||||
# Always update SSL status and errors from current response
|
||||
ssl_settings['cf_status'] = ssl_record&.dig('status')
|
||||
ssl_settings['cf_verification_errors'] = verification_errors
|
||||
|
||||
portal.update(ssl_settings: ssl_settings)
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user