Merge branch 'develop' into feat/asset-service-workers
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
3.4.0
|
||||
3.4.1
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -4,7 +4,11 @@ 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 { 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 +17,19 @@ defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
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 +43,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 +65,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({
|
||||
@@ -101,7 +120,14 @@ onMounted(() => {
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -106,42 +106,44 @@ 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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
{ 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')
|
||||
|
||||
@@ -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>
|
||||
@@ -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.1
|
||||
# Run this script as root
|
||||
|
||||
set -eu -o errexit -o pipefail -o noclobber -o nounset
|
||||
@@ -1005,7 +1005,7 @@ EOF
|
||||
upgrade_redis
|
||||
upgrade_node
|
||||
get_pnpm
|
||||
sudo -i -u chatwoot << "EOF"
|
||||
sudo -i -u chatwoot << EOF
|
||||
|
||||
# Navigate to the Chatwoot directory
|
||||
cd chatwoot
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -14,21 +14,10 @@ class Cloudflare::CheckCustomHostnameService < Cloudflare::BaseCloudflareZoneSer
|
||||
data = response.parsed_response['result']
|
||||
|
||||
if data.present?
|
||||
update_portal_ssl_settings(data.first)
|
||||
update_portal_ssl_settings(@portal, data.first)
|
||||
return { data: data }
|
||||
end
|
||||
|
||||
{ errors: ['Hostname is missing in Cloudflare'] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_portal_ssl_settings(data)
|
||||
verification_record = data['ownership_verification_http']
|
||||
ssl_settings = {
|
||||
'cf_verification_id': verification_record['http_url'].split('/').last,
|
||||
'cf_verification_body': verification_record['http_body']
|
||||
}
|
||||
@portal.update(ssl_settings: ssl_settings)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
|
||||
data = response.parsed_response['result']
|
||||
|
||||
if data.present?
|
||||
update_portal_ssl_settings(data)
|
||||
update_portal_ssl_settings(@portal, data)
|
||||
return { data: data }
|
||||
end
|
||||
|
||||
@@ -25,16 +25,13 @@ class Cloudflare::CreateCustomHostnameService < Cloudflare::BaseCloudflareZoneSe
|
||||
HTTParty.post(
|
||||
"#{BASE_URI}/zones/#{zone_id}/custom_hostnames",
|
||||
headers: headers,
|
||||
body: { hostname: @portal.custom_domain }.to_json
|
||||
body: {
|
||||
hostname: @portal.custom_domain,
|
||||
ssl: {
|
||||
method: 'http',
|
||||
type: 'dv'
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def update_portal_ssl_settings(data)
|
||||
verification_record = data['ownership_verification_http']
|
||||
ssl_settings = {
|
||||
'cf_verification_id': verification_record['http_url'].split('/').last,
|
||||
'cf_verification_body': verification_record['http_body']
|
||||
}
|
||||
@portal.update(ssl_settings: ssl_settings)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,7 +15,8 @@ class Llm::BaseOpenAiService
|
||||
private
|
||||
|
||||
def uri_base
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || 'https://api.openai.com/'
|
||||
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
||||
endpoint.presence || 'https://api.openai.com/'
|
||||
end
|
||||
|
||||
def setup_model
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
json.data do
|
||||
json.payload do
|
||||
json.array! @scenarios do |scenario|
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: scenario
|
||||
end
|
||||
end
|
||||
|
||||
json.meta do
|
||||
json.total_count @scenarios.count
|
||||
json.page 1
|
||||
end
|
||||
|
||||
+3
-3
@@ -33,8 +33,8 @@
|
||||
"dependencies": {
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.1.6-next",
|
||||
"@chatwoot/utils": "^0.0.48",
|
||||
"@chatwoot/prosemirror-schema": "1.2.1",
|
||||
"@chatwoot/utils": "^0.0.49",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"vue-chartjs": "5.3.1",
|
||||
"vue-datepicker-next": "^1.0.3",
|
||||
"vue-dompurify-html": "^5.1.0",
|
||||
"vue-i18n": "9.14.3",
|
||||
"vue-i18n": "9.14.5",
|
||||
"vue-letter": "^0.2.1",
|
||||
"vue-multiselect": "3.1.0",
|
||||
"vue-router": "~4.4.5",
|
||||
|
||||
Generated
+70
-39
@@ -20,11 +20,11 @@ importers:
|
||||
specifier: 1.2.3
|
||||
version: 1.2.3
|
||||
'@chatwoot/prosemirror-schema':
|
||||
specifier: 1.1.6-next
|
||||
version: 1.1.6-next
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@chatwoot/utils':
|
||||
specifier: ^0.0.48
|
||||
version: 0.0.48
|
||||
specifier: ^0.0.49
|
||||
version: 0.0.49
|
||||
'@formkit/core':
|
||||
specifier: ^1.6.7
|
||||
version: 1.6.7
|
||||
@@ -200,8 +200,8 @@ importers:
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.0(vue@3.5.12(typescript@5.6.2))
|
||||
vue-i18n:
|
||||
specifier: 9.14.3
|
||||
version: 9.14.3(vue@3.5.12(typescript@5.6.2))
|
||||
specifier: 9.14.5
|
||||
version: 9.14.5(vue@3.5.12(typescript@5.6.2))
|
||||
vue-letter:
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1
|
||||
@@ -403,11 +403,11 @@ packages:
|
||||
'@chatwoot/ninja-keys@1.2.3':
|
||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.1.6-next':
|
||||
resolution: {integrity: sha512-9lf7FrcED/B5oyGrMmIkbegkhlC/P0NrtXoX8k94YWRosZcx0hGVGhpTud+0Mhm7saAfGerKIwTRVDmmnxPuCA==}
|
||||
'@chatwoot/prosemirror-schema@1.2.1':
|
||||
resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
|
||||
|
||||
'@chatwoot/utils@0.0.48':
|
||||
resolution: {integrity: sha512-67M2lvpBp0Ciczv1uRzabOXSCGiEeJE3wYVoPAxkqI35CJSkotu4tSX2TFOwagUQoRyU6F8YV3xXGfCpDN9WAA==}
|
||||
'@chatwoot/utils@0.0.49':
|
||||
resolution: {integrity: sha512-Co68VzaFtctTNYKY6y4izBBATvk6/8ZVtkyEP5HL72uhFDA11LrY5pqSh04HMoFyfdIU+uVPimfI45HAeso1IA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@codemirror/commands@6.7.0':
|
||||
@@ -906,8 +906,8 @@ packages:
|
||||
resolution: {integrity: sha512-DZyQ4Hk22sC81MP4qiCDuU+LdaYW91A6lCjq8AWPvY3+mGMzhGDfOCzvyR6YBQxtlPjFqMoFk9ylnNYRAQwXtQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
resolution: {integrity: sha512-nbJ7pKTlXFnaXPblyfiH6awAx1C0PWNNuqXAR74yRwgi5A/Re/8/5fErLY0pv4R8+EHj3ZaThMHdnuC/5OBa6g==}
|
||||
'@intlify/core-base@9.14.5':
|
||||
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/eslint-plugin-vue-i18n@3.2.0':
|
||||
@@ -920,16 +920,16 @@ packages:
|
||||
resolution: {integrity: sha512-YsKKuV4Qv4wrLNsvgWbTf0E40uRv+Qiw1BeLQ0LAxifQuhiMe+hfTIzOMdWj/ZpnTDj4RSZtkXjJM7JDiiB5LQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/message-compiler@9.14.3':
|
||||
resolution: {integrity: sha512-ANwC226BQdd+MpJ36rOYkChSESfPwu3Ss2Faw0RHTOknYLoHTX6V6e/JjIKVDMbzs0/H/df/rO6yU0SPiWHqNg==}
|
||||
'@intlify/message-compiler@9.14.5':
|
||||
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@9.14.2':
|
||||
resolution: {integrity: sha512-uRAHAxYPeF+G5DBIboKpPgC/Waecd4Jz8ihtkpJQD5ycb5PwXp0k/+hBGl5dAjwF7w+l74kz/PKA8r8OK//RUw==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@intlify/shared@9.14.3':
|
||||
resolution: {integrity: sha512-hJXz9LA5VG7qNE00t50bdzDv8Z4q9fpcL81wj4y4duKavrv0KM8YNLTwXNEFINHjTsfrG9TXvPuEjVaAvZ7yWg==}
|
||||
'@intlify/shared@9.14.5':
|
||||
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
@@ -940,6 +940,9 @@ packages:
|
||||
resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.12':
|
||||
resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.5':
|
||||
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -952,19 +955,29 @@ packages:
|
||||
resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/set-array@1.2.1':
|
||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
||||
'@jridgewell/source-map@0.3.10':
|
||||
resolution: {integrity: sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.4':
|
||||
resolution: {integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
resolution: {integrity: sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==}
|
||||
|
||||
'@june-so/analytics-next@2.0.0':
|
||||
resolution: {integrity: sha512-7uFP94JLD7mP4qLyOwn5HBs+CC8VlevOkiGd1CIYqPSjSRmbCOI+MVcJNlTAcpyNvMi9iUnWZ3jGVO5177Di4A==}
|
||||
|
||||
@@ -1993,8 +2006,8 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
acorn@8.14.1:
|
||||
resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==}
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4960,8 +4973,8 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: '>=6.0.0'
|
||||
|
||||
vue-i18n@9.14.3:
|
||||
resolution: {integrity: sha512-C+E0KE8ihKjdYCQx8oUkXX+8tBItrYNMnGJuzEPevBARQFUN2tKez6ZVOvBrWH0+KT5wEk3vOWjNk7ygb2u9ig==}
|
||||
vue-i18n@9.14.5:
|
||||
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
@@ -5237,7 +5250,7 @@ snapshots:
|
||||
hotkeys-js: 3.8.7
|
||||
lit: 2.2.6
|
||||
|
||||
'@chatwoot/prosemirror-schema@1.1.6-next':
|
||||
'@chatwoot/prosemirror-schema@1.2.1':
|
||||
dependencies:
|
||||
markdown-it-sup: 2.0.0
|
||||
prosemirror-commands: 1.6.0
|
||||
@@ -5255,7 +5268,7 @@ snapshots:
|
||||
prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3)
|
||||
prosemirror-view: 1.34.1
|
||||
|
||||
'@chatwoot/utils@0.0.48':
|
||||
'@chatwoot/utils@0.0.49':
|
||||
dependencies:
|
||||
date-fns: 2.30.0
|
||||
|
||||
@@ -5778,10 +5791,10 @@ snapshots:
|
||||
'@intlify/message-compiler': 9.14.2
|
||||
'@intlify/shared': 9.14.2
|
||||
|
||||
'@intlify/core-base@9.14.3':
|
||||
'@intlify/core-base@9.14.5':
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 9.14.3
|
||||
'@intlify/shared': 9.14.3
|
||||
'@intlify/message-compiler': 9.14.5
|
||||
'@intlify/shared': 9.14.5
|
||||
|
||||
'@intlify/eslint-plugin-vue-i18n@3.2.0(eslint@8.57.0)':
|
||||
dependencies:
|
||||
@@ -5813,14 +5826,14 @@ snapshots:
|
||||
'@intlify/shared': 9.14.2
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/message-compiler@9.14.3':
|
||||
'@intlify/message-compiler@9.14.5':
|
||||
dependencies:
|
||||
'@intlify/shared': 9.14.3
|
||||
'@intlify/shared': 9.14.5
|
||||
source-map-js: 1.2.1
|
||||
|
||||
'@intlify/shared@9.14.2': {}
|
||||
|
||||
'@intlify/shared@9.14.3': {}
|
||||
'@intlify/shared@9.14.5': {}
|
||||
|
||||
'@isaacs/cliui@8.0.2':
|
||||
dependencies:
|
||||
@@ -5833,6 +5846,12 @@ snapshots:
|
||||
|
||||
'@istanbuljs/schema@0.1.3': {}
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.12':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
optional: true
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.5':
|
||||
dependencies:
|
||||
'@jridgewell/set-array': 1.2.1
|
||||
@@ -5847,21 +5866,33 @@ snapshots:
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.1': {}
|
||||
|
||||
'@jridgewell/resolve-uri@3.1.2':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/set-array@1.2.1': {}
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
'@jridgewell/source-map@0.3.10':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@jridgewell/gen-mapping': 0.3.12
|
||||
'@jridgewell/trace-mapping': 0.3.29
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.4':
|
||||
optional: true
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.1
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.29':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
optional: true
|
||||
|
||||
'@june-so/analytics-next@2.0.0':
|
||||
dependencies:
|
||||
'@lukeed/uuid': 2.0.0
|
||||
@@ -7078,7 +7109,7 @@ snapshots:
|
||||
|
||||
acorn@8.14.0: {}
|
||||
|
||||
acorn@8.14.1:
|
||||
acorn@8.15.0:
|
||||
optional: true
|
||||
|
||||
activestorage@5.2.8:
|
||||
@@ -10124,8 +10155,8 @@ snapshots:
|
||||
|
||||
terser@5.33.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
acorn: 8.14.1
|
||||
'@jridgewell/source-map': 0.3.10
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
optional: true
|
||||
@@ -10479,10 +10510,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-i18n@9.14.3(vue@3.5.12(typescript@5.6.2)):
|
||||
vue-i18n@9.14.5(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
'@intlify/core-base': 9.14.3
|
||||
'@intlify/shared': 9.14.3
|
||||
'@intlify/core-base': 9.14.5
|
||||
'@intlify/shared': 9.14.5
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
|
||||
|
||||
@@ -210,4 +210,76 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/portals/{portal.slug}/send_instructions' do
|
||||
let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
|
||||
params: { email: 'dev@example.com' }
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: { email: 'dev@example.com' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated admin' do
|
||||
it 'returns error when email is missing' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: {},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Email is required')
|
||||
end
|
||||
|
||||
it 'returns error when email is invalid' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { email: 'invalid-email' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid email format')
|
||||
end
|
||||
|
||||
it 'returns error when custom domain is not configured' do
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/send_instructions",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { email: 'dev@example.com' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Custom domain is not configured')
|
||||
end
|
||||
|
||||
it 'sends instructions successfully' do
|
||||
mailer_double = instance_double(ActionMailer::MessageDelivery)
|
||||
allow(PortalInstructionsMailer).to receive(:send_cname_instructions).and_return(mailer_double)
|
||||
allow(mailer_double).to receive(:deliver_later)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/send_instructions",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { email: 'dev@example.com' },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['message']).to eq('Instructions sent successfully')
|
||||
expect(PortalInstructionsMailer).to have_received(:send_cname_instructions)
|
||||
.with(portal: portal_with_domain, recipient_email: 'dev@example.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -26,7 +26,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:data].length).to eq(3)
|
||||
expect(json_response[:payload].length).to eq(3)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,7 +38,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:data].length).to eq(5)
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns only enabled scenarios' do
|
||||
@@ -49,8 +49,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(json_response[:data].length).to eq(1)
|
||||
expect(json_response[:data].first[:enabled]).to be(true)
|
||||
expect(json_response[:payload].length).to eq(1)
|
||||
expect(json_response[:payload].first[:enabled]).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,4 +87,73 @@ RSpec.describe 'Enterprise Portal API', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/portals/{portal.slug}/ssl_status' do
|
||||
let(:portal_with_domain) { create(:portal, slug: 'portal-with-domain', account_id: account.id, custom_domain: 'docs.example.com') }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns error when custom domain is not configured' do
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Custom domain is not configured')
|
||||
end
|
||||
|
||||
it 'returns SSL status when portal has ssl_settings' do
|
||||
portal_with_domain.update(ssl_settings: {
|
||||
'cf_status' => 'active',
|
||||
'cf_verification_errors' => nil
|
||||
})
|
||||
|
||||
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
|
||||
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:perform).and_return({ data: [] })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['status']).to eq('active')
|
||||
expect(response.parsed_body['verification_errors']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns null values when portal has no ssl_settings' do
|
||||
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
|
||||
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:perform).and_return({ data: [] })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['status']).to be_nil
|
||||
expect(response.parsed_body['verification_errors']).to be_nil
|
||||
end
|
||||
|
||||
it 'returns error when Cloudflare service returns errors' do
|
||||
mock_service = instance_double(Cloudflare::CheckCustomHostnameService)
|
||||
allow(Cloudflare::CheckCustomHostnameService).to receive(:new).and_return(mock_service)
|
||||
allow(mock_service).to receive(:perform).and_return({ errors: ['API token not found'] })
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/portals/#{portal_with_domain.slug}/ssl_status",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(['API token not found'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -96,8 +96,10 @@ RSpec.describe Cloudflare::CheckCustomHostnameService do
|
||||
|
||||
expect(portal).to receive(:update).with(
|
||||
ssl_settings: {
|
||||
'cf_verification_id': 'verification-id',
|
||||
'cf_verification_body': 'verification-body'
|
||||
'cf_verification_id' => 'verification-id',
|
||||
'cf_verification_body' => 'verification-body',
|
||||
'cf_status' => nil,
|
||||
'cf_verification_errors' => ''
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
|
||||
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
|
||||
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
|
||||
body: { hostname: 'test.example.com' }.to_json)
|
||||
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
|
||||
.to_return(status: 422, body: error_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.perform
|
||||
@@ -72,7 +72,7 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
|
||||
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
|
||||
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
|
||||
body: { hostname: 'test.example.com' }.to_json)
|
||||
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
result = service.perform
|
||||
@@ -92,17 +92,22 @@ RSpec.describe Cloudflare::CreateCustomHostnameService do
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(portal.ssl_settings).to eq({})
|
||||
|
||||
stub_request(:post, 'https://api.cloudflare.com/client/v4/zones/test-zone-id/custom_hostnames')
|
||||
.with(headers: { 'Authorization' => 'Bearer test-api-key', 'Content-Type' => 'application/json' },
|
||||
body: { hostname: 'test.example.com' }.to_json)
|
||||
body: { hostname: 'test.example.com', ssl: { method: 'http', type: 'dv' } }.to_json)
|
||||
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(portal).to receive(:update).with(ssl_settings: { 'cf_verification_id': 'verification-id',
|
||||
'cf_verification_body': 'verification-body' })
|
||||
|
||||
result = service.perform
|
||||
|
||||
expect(portal.ssl_settings).to eq(
|
||||
{
|
||||
'cf_verification_id' => 'verification-id',
|
||||
'cf_verification_body' => 'verification-body',
|
||||
'cf_status' => nil,
|
||||
'cf_verification_errors' => ''
|
||||
}
|
||||
)
|
||||
expect(result).to eq(data: success_response['result'])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PortalInstructionsMailer do
|
||||
describe 'send_cname_instructions' do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:portal) { create(:portal, account: account, custom_domain: 'help.example.com') }
|
||||
let(:recipient_email) { 'admin@example.com' }
|
||||
let(:class_instance) { described_class.new }
|
||||
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(class_instance)
|
||||
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when target domain is available' do
|
||||
it 'sends email with cname instructions' do
|
||||
with_modified_env HELPCENTER_URL: 'https://help.chatwoot.com' do
|
||||
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
|
||||
|
||||
expect(mail.to).to eq([recipient_email])
|
||||
expect(mail.subject).to eq("Finish setting up #{portal.custom_domain}")
|
||||
expect(mail.body.encoded).to include('help.example.com CNAME help.chatwoot.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when helpcenter url is not available but frontend url is' do
|
||||
it 'uses frontend url as target domain' do
|
||||
with_modified_env HELPCENTER_URL: '', FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
|
||||
|
||||
expect(mail.to).to eq([recipient_email])
|
||||
expect(mail.body.encoded).to include('help.example.com CNAME app.chatwoot.com')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no target domain is available' do
|
||||
it 'does not send email' do
|
||||
with_modified_env HELPCENTER_URL: '', FRONTEND_URL: '' do
|
||||
mail = described_class.send_cname_instructions(portal: portal, recipient_email: recipient_email).deliver_now
|
||||
|
||||
expect(mail).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user