feat(captain): add audience and schedule controls for assistants
Audience: a nested condition builder (contact attributes, labels, conversation language, and a logged-in/HMAC-verified flag) evaluated in-memory by Captain::AudienceMatcher. Gates Captain at the reply, new-conversation routing, reopen, and template-suppression sites so out-of-audience conversations go to the human queue. Schedule: a response window (anytime / during business hours / outside business hours) reusing each inbox's business hours. Off-schedule conversations are handed to the team; inboxes without business hours are always covered.
This commit is contained in:
+147
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
import { ref, watch, useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import AudienceGroup from './audience/AudienceGroup.vue';
|
||||
import { useAudienceFilterTypes } from './audience/useAudienceFilterTypes.js';
|
||||
|
||||
const props = defineProps({
|
||||
assistant: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { filterTypes } = useAudienceFilterTypes();
|
||||
|
||||
let uid = 0;
|
||||
const nextId = () => {
|
||||
uid += 1;
|
||||
return `audience-root-${uid}`;
|
||||
};
|
||||
|
||||
const hasConditions = node =>
|
||||
node && Object.prototype.hasOwnProperty.call(node, 'conditions');
|
||||
|
||||
const defaultRoot = () => ({ id: nextId(), operator: 'and', conditions: [] });
|
||||
|
||||
const root = ref(defaultRoot());
|
||||
|
||||
const findOption = (filterType, value) =>
|
||||
filterType?.options?.find(option => String(option.id) === String(value));
|
||||
|
||||
const hydrateValues = (leaf, filterType) => {
|
||||
const raw = Array.isArray(leaf.values) ? leaf.values : [leaf.values];
|
||||
const inputType = filterType?.inputType;
|
||||
if (inputType === 'multiSelect') {
|
||||
return raw.map(
|
||||
value => findOption(filterType, value) ?? { id: value, name: value }
|
||||
);
|
||||
}
|
||||
if (['searchSelect', 'booleanSelect'].includes(inputType)) {
|
||||
return findOption(filterType, raw[0]) ?? { id: raw[0], name: raw[0] };
|
||||
}
|
||||
return raw[0] ?? '';
|
||||
};
|
||||
|
||||
const hydrateNode = node => {
|
||||
if (hasConditions(node)) {
|
||||
return {
|
||||
id: nextId(),
|
||||
operator: node.operator || 'and',
|
||||
conditions: (node.conditions || []).map(hydrateNode),
|
||||
};
|
||||
}
|
||||
|
||||
const filterType = filterTypes.value.find(
|
||||
type => type.attributeKey === node.attribute_key
|
||||
);
|
||||
return {
|
||||
id: nextId(),
|
||||
attributeKey: node.attribute_key,
|
||||
filterOperator: node.filter_operator,
|
||||
values: hydrateValues(node, filterType),
|
||||
attributeModel: filterType?.attributeModel || 'standard',
|
||||
};
|
||||
};
|
||||
|
||||
const hydrateRoot = audience => {
|
||||
if (!audience) return defaultRoot();
|
||||
const hydrated = hydrateNode(audience);
|
||||
return hasConditions(hydrated)
|
||||
? hydrated
|
||||
: { id: nextId(), operator: 'and', conditions: [hydrated] };
|
||||
};
|
||||
|
||||
const serializeValues = values => {
|
||||
if (Array.isArray(values)) {
|
||||
return values[0]?.id ? values.map(value => value.id) : values;
|
||||
}
|
||||
if (values && typeof values === 'object') {
|
||||
return [values.id];
|
||||
}
|
||||
if (values === '' || values === null || values === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [values];
|
||||
};
|
||||
|
||||
const serializeNode = node => {
|
||||
if (hasConditions(node)) {
|
||||
return {
|
||||
operator: node.operator,
|
||||
conditions: node.conditions.map(serializeNode),
|
||||
};
|
||||
}
|
||||
return {
|
||||
attribute_key: node.attributeKey,
|
||||
filter_operator: node.filterOperator,
|
||||
values: serializeValues(node.values),
|
||||
};
|
||||
};
|
||||
|
||||
const groupRef = useTemplateRef('groupRef');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!groupRef.value.validate()) return;
|
||||
|
||||
const audience = root.value.conditions.length
|
||||
? serializeNode(root.value)
|
||||
: null;
|
||||
|
||||
emit('submit', {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
audience,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.assistant,
|
||||
newAssistant => {
|
||||
if (newAssistant) root.value = hydrateRoot(newAssistant.config?.audience);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<AudienceGroup
|
||||
ref="groupRef"
|
||||
v-model="root"
|
||||
is-root
|
||||
:filter-types="filterTypes"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistant: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const OPTIONS = ['always', 'business_hours', 'outside_business_hours'];
|
||||
|
||||
const selected = ref('always');
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', {
|
||||
config: { ...props.assistant.config, response_window: selected.value },
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.assistant,
|
||||
newAssistant => {
|
||||
if (newAssistant) {
|
||||
selected.value = newAssistant.config?.response_window || 'always';
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<RadioCard
|
||||
v-for="option in OPTIONS"
|
||||
:id="option"
|
||||
:key="option"
|
||||
:label="
|
||||
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.LABEL`)
|
||||
"
|
||||
:description="
|
||||
t(`CAPTAIN.ASSISTANTS.FORM.SCHEDULE.${option.toUpperCase()}.DESC`)
|
||||
"
|
||||
:is-active="selected === option"
|
||||
@select="selected = $event"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.SCHEDULE.HINT') }}
|
||||
</p>
|
||||
<div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<script setup>
|
||||
import { useTemplateRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'next/button/Button.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
|
||||
const props = defineProps({
|
||||
filterTypes: { type: Array, required: true },
|
||||
depth: { type: Number, default: 0 },
|
||||
maxDepth: { type: Number, default: 1 },
|
||||
isRoot: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
|
||||
const node = defineModel({ type: Object, required: true });
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
let uid = 0;
|
||||
const nextId = () => {
|
||||
uid += 1;
|
||||
return `audience-${Date.now()}-${uid}`;
|
||||
};
|
||||
|
||||
const DEFAULT_LEAF = () => ({
|
||||
id: nextId(),
|
||||
attributeKey: 'email',
|
||||
filterOperator: 'contains',
|
||||
values: '',
|
||||
attributeModel: 'standard',
|
||||
});
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: 'and', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ALL') },
|
||||
{ value: 'or', label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_ANY') },
|
||||
];
|
||||
|
||||
const isGroup = child =>
|
||||
Object.prototype.hasOwnProperty.call(child, 'conditions');
|
||||
|
||||
const addCondition = () => {
|
||||
node.value.conditions.push(DEFAULT_LEAF());
|
||||
};
|
||||
|
||||
const addGroup = () => {
|
||||
node.value.conditions.push({
|
||||
id: nextId(),
|
||||
operator: 'and',
|
||||
conditions: [DEFAULT_LEAF()],
|
||||
});
|
||||
};
|
||||
|
||||
const removeChild = index => {
|
||||
node.value.conditions.splice(index, 1);
|
||||
// A nested group must always hold at least one condition; remove the whole group when emptied.
|
||||
if (!props.isRoot && node.value.conditions.length === 0) {
|
||||
emit('remove');
|
||||
}
|
||||
};
|
||||
|
||||
const leafRefs = useTemplateRef('leafRefs');
|
||||
const groupRefs = useTemplateRef('groupRefs');
|
||||
|
||||
const validate = () => {
|
||||
const leavesValid = (leafRefs.value ?? []).every(row => row.validate());
|
||||
const groupsValid = (groupRefs.value ?? []).every(group => group.validate());
|
||||
return leavesValid && groupsValid;
|
||||
};
|
||||
|
||||
defineExpose({ validate });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-4 group/audience-group"
|
||||
:class="
|
||||
isRoot
|
||||
? 'p-4 border border-n-weak rounded-xl'
|
||||
: 'p-3 border border-n-weak bg-n-alpha-1 rounded-lg'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5 text-sm text-n-slate-11">
|
||||
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_PREFIX') }}</span>
|
||||
<FilterSelect
|
||||
v-model="node.operator"
|
||||
variant="faded"
|
||||
hide-icon
|
||||
class="text-sm"
|
||||
:options="operatorOptions"
|
||||
/>
|
||||
<span>{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.MATCH_SUFFIX') }}</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="!isRoot"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
icon="i-lucide-trash"
|
||||
class="ml-auto flex-shrink-0 opacity-0 transition-opacity group-hover/audience-group:opacity-100"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ul class="grid gap-3 list-none">
|
||||
<template v-for="(child, index) in node.conditions" :key="child.id">
|
||||
<AudienceGroup
|
||||
v-if="isGroup(child)"
|
||||
ref="groupRefs"
|
||||
v-model="node.conditions[index]"
|
||||
:filter-types="filterTypes"
|
||||
:depth="depth + 1"
|
||||
:max-depth="maxDepth"
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="leafRefs"
|
||||
v-model:attribute-key="child.attributeKey"
|
||||
v-model:filter-operator="child.filterOperator"
|
||||
v-model:values="child.values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
searchable-attributes
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="isRoot && !node.conditions.length"
|
||||
class="flex flex-col items-center gap-1.5 px-4 py-8 text-center border border-dashed rounded-lg border-n-weak"
|
||||
>
|
||||
<span class="text-2xl i-lucide-users-round text-n-slate-10" />
|
||||
<p class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_TITLE') }}
|
||||
</p>
|
||||
<p class="max-w-md text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.EMPTY_BODY') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
class="flex-shrink-0"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_CONDITION')"
|
||||
@click="addCondition"
|
||||
/>
|
||||
<Button
|
||||
v-if="depth < maxDepth"
|
||||
sm
|
||||
ghost
|
||||
blue
|
||||
icon="i-lucide-plus"
|
||||
class="flex-shrink-0"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.ADD_GROUP')"
|
||||
@click="addGroup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useContactFilterContext } from 'dashboard/components-next/filter/contactProvider.js';
|
||||
import { useOperators } from 'dashboard/components-next/filter/operators.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
// Icons for the standard contact attributes, keyed by attribute key.
|
||||
const STANDARD_ICONS = {
|
||||
name: 'i-lucide-user',
|
||||
email: 'i-lucide-mail',
|
||||
phone_number: 'i-lucide-phone',
|
||||
identifier: 'i-lucide-fingerprint',
|
||||
country_code: 'i-lucide-flag',
|
||||
city: 'i-lucide-map-pin',
|
||||
company_name: 'i-lucide-building-2',
|
||||
created_at: 'i-lucide-calendar',
|
||||
last_activity_at: 'i-lucide-activity',
|
||||
blocked: 'i-lucide-ban',
|
||||
labels: 'i-lucide-tags',
|
||||
browser_language: 'i-lucide-globe',
|
||||
conversation_language: 'i-lucide-languages',
|
||||
};
|
||||
|
||||
// Icons for custom attributes, keyed by the attribute's display type.
|
||||
const CUSTOM_TYPE_ICONS = {
|
||||
text: 'i-lucide-type',
|
||||
number: 'i-lucide-hash',
|
||||
currency: 'i-lucide-banknote',
|
||||
percent: 'i-lucide-percent',
|
||||
link: 'i-lucide-link',
|
||||
date: 'i-lucide-calendar',
|
||||
list: 'i-lucide-list',
|
||||
checkbox: 'i-lucide-square-check',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'i-lucide-tag';
|
||||
|
||||
/**
|
||||
* Filter types for a Captain assistant audience: contact attributes (reused from the contact
|
||||
* segment builder) plus the two conversation language fields. Each option carries an icon, and the
|
||||
* options are split into grouped sections (contact / conversation / custom) via disabled headers,
|
||||
* which FilterSelect renders as non-clickable section titles.
|
||||
*
|
||||
* @returns {{ filterTypes: import('vue').ComputedRef<Array> }}
|
||||
*/
|
||||
export function useAudienceFilterTypes() {
|
||||
const { t } = useI18n();
|
||||
const { filterTypes: contactFilterTypes } = useContactFilterContext();
|
||||
const { equalityOperators } = useOperators();
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
// Map custom attribute key -> display type, so each custom option gets a type-based icon.
|
||||
const customTypeByKey = computed(() =>
|
||||
(contactAttributes.value || []).reduce((acc, attr) => {
|
||||
acc[attr.attributeKey] = attr.attributeDisplayType;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// Conversation-level attributes: the logged-in (HMAC verified) flag and the language fields.
|
||||
// Languages use a searchable dropdown (same option list as automation rules), not free text.
|
||||
const conversationFilterTypes = computed(() => [
|
||||
{
|
||||
attributeKey: 'hmac_verified',
|
||||
value: 'hmac_verified',
|
||||
attributeName: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
|
||||
label: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN'),
|
||||
icon: 'i-lucide-user-check',
|
||||
inputType: 'searchSelect',
|
||||
options: [
|
||||
{
|
||||
id: 'true',
|
||||
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_TRUE'),
|
||||
},
|
||||
{
|
||||
id: 'false',
|
||||
name: t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.LOGGED_IN_FALSE'),
|
||||
},
|
||||
],
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
...['browser_language', 'conversation_language'].map(key => ({
|
||||
attributeKey: key,
|
||||
value: key,
|
||||
attributeName: t(`CAPTAIN.ASSISTANTS.FORM.AUDIENCE.${key.toUpperCase()}`),
|
||||
label: t(`CAPTAIN.ASSISTANTS.FORM.AUDIENCE.${key.toUpperCase()}`),
|
||||
icon: STANDARD_ICONS[key],
|
||||
inputType: 'searchSelect',
|
||||
options: languages,
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
})),
|
||||
]);
|
||||
|
||||
const header = (id, label) => ({
|
||||
value: `__group_${id}`,
|
||||
label,
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const filterTypes = computed(() => {
|
||||
const standard = [];
|
||||
const custom = [];
|
||||
|
||||
contactFilterTypes.value.forEach(type => {
|
||||
if (type.attributeModel === 'customAttributes') {
|
||||
// Only contact-model custom attributes belong here; never conversation/company ones.
|
||||
if (!(type.attributeKey in customTypeByKey.value)) return;
|
||||
const displayType = customTypeByKey.value[type.attributeKey];
|
||||
custom.push({
|
||||
...type,
|
||||
icon: CUSTOM_TYPE_ICONS[displayType] || DEFAULT_ICON,
|
||||
});
|
||||
} else {
|
||||
standard.push({
|
||||
...type,
|
||||
icon: STANDARD_ICONS[type.attributeKey] || DEFAULT_ICON,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
header('contact', t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONTACT')),
|
||||
...standard,
|
||||
header(
|
||||
'conversation',
|
||||
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CONVERSATION')
|
||||
),
|
||||
...conversationFilterTypes.value,
|
||||
...(custom.length
|
||||
? [
|
||||
header(
|
||||
'custom',
|
||||
t('CAPTAIN.ASSISTANTS.FORM.AUDIENCE.GROUP_CUSTOM')
|
||||
),
|
||||
...custom,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
return { filterTypes };
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { validateSingleFilter } from 'dashboard/helper/validations.js';
|
||||
const { filterTypes } = defineProps({
|
||||
showQueryOperator: { type: Boolean, default: false },
|
||||
filterTypes: { type: Array, required: true },
|
||||
searchableAttributes: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove']);
|
||||
@@ -199,6 +200,7 @@ defineExpose({ validate, resetValidation });
|
||||
v-model="attributeKey"
|
||||
variant="faded"
|
||||
:options="filterTypes"
|
||||
:searchable="searchableAttributes"
|
||||
@update:model-value="resetModelOnAttributeKeyChange"
|
||||
/>
|
||||
<FilterSelect
|
||||
|
||||
@@ -165,16 +165,6 @@ export function useContactFilterContext() {
|
||||
filterOperators: dateOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.REFERER,
|
||||
value: CONTACT_ATTRIBUTES.REFERER,
|
||||
attributeName: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
label: t('CONTACTS_LAYOUT.FILTER.REFERER_LINK'),
|
||||
inputType: 'plainText',
|
||||
dataType: 'text',
|
||||
filterOperators: containmentOperators.value,
|
||||
attributeModel: 'standard',
|
||||
},
|
||||
{
|
||||
attributeKey: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
value: CONTACT_ATTRIBUTES.BLOCKED,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
import DropdownContainer from 'next/dropdown-menu/base/DropdownContainer.vue';
|
||||
import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
||||
@@ -7,6 +8,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||
|
||||
import Button from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
// [{label, icon, value}]
|
||||
const props = defineProps({
|
||||
@@ -30,6 +32,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const selected = defineModel({
|
||||
@@ -37,9 +43,21 @@ const selected = defineModel({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const triggerRef = ref(null);
|
||||
const dropdownRef = ref(null);
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!props.searchable || !query) return props.options;
|
||||
return props.options.filter(
|
||||
option =>
|
||||
!option.disabled && (option.label || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const { top } = useElementBounding(triggerRef);
|
||||
const { height } = useWindowSize();
|
||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||
@@ -65,6 +83,12 @@ const dropdownPosition = computed(() => {
|
||||
|
||||
const updateSelected = newValue => {
|
||||
selected.value = newValue;
|
||||
searchQuery.value = '';
|
||||
};
|
||||
|
||||
const handleTriggerClick = toggle => {
|
||||
searchQuery.value = '';
|
||||
toggle();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -81,7 +105,7 @@ const updateSelected = newValue => {
|
||||
:icon="iconToRender"
|
||||
:trailing-icon="selectedOption.icon ? false : true"
|
||||
:label="label || (hideLabel ? null : selectedOption.label)"
|
||||
@click="toggle"
|
||||
@click="handleTriggerClick(toggle)"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
@@ -91,8 +115,17 @@ const updateSelected = newValue => {
|
||||
:class="dropdownPosition"
|
||||
strong
|
||||
>
|
||||
<div v-if="searchable" class="relative">
|
||||
<Icon class="absolute size-4 left-2 top-2" icon="i-lucide-search" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
v-focus
|
||||
class="w-full p-1.5 pl-8 rounded-lg text-n-slate-11 bg-n-alpha-1"
|
||||
:placeholder="t('FILTER.SEARCH_PLACEHOLDER')"
|
||||
/>
|
||||
</div>
|
||||
<DropdownSection class="[&>ul]:max-h-72">
|
||||
<template v-for="option in options" :key="option.value">
|
||||
<template v-for="option in filteredOptions" :key="option.value">
|
||||
<li
|
||||
v-if="option.disabled"
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||
@@ -106,6 +139,12 @@ const updateSelected = newValue => {
|
||||
@click="updateSelected(option.value)"
|
||||
/>
|
||||
</template>
|
||||
<li
|
||||
v-if="searchable && !filteredOptions.length"
|
||||
class="px-2 py-1.5 text-sm text-n-slate-10 select-none"
|
||||
>
|
||||
{{ t('FILTER.NO_RESULTS') }}
|
||||
</li>
|
||||
</DropdownSection>
|
||||
</DropdownBody>
|
||||
</DropdownContainer>
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"OR": "OR"
|
||||
},
|
||||
"INPUT_PLACEHOLDER": "Enter value",
|
||||
"SEARCH_PLACEHOLDER": "Search…",
|
||||
"NO_RESULTS": "No matches",
|
||||
"CONTACT_SEARCH_PLACEHOLDER": "Search contacts",
|
||||
"CONTACT_FALLBACK": "Contact #{id}",
|
||||
"OPERATOR_LABELS": {
|
||||
|
||||
@@ -530,6 +530,39 @@
|
||||
"ALLOW_MEMORIES": "Capture key details as memories from customer interactions.",
|
||||
"ALLOW_CITATIONS": "Include source citations in responses",
|
||||
"ALLOW_CONTACT_ATTRIBUTES": "Allow access to contact information"
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"ADD_CONDITION": "Add condition",
|
||||
"ADD_GROUP": "Add condition group",
|
||||
"EMPTY_TITLE": "No conditions yet",
|
||||
"EMPTY_BODY": "By default, Captain responds to every conversation in connected inboxes. Add a condition to target specific customers, or a condition group to combine conditions with different and/or logic.",
|
||||
"MATCH_PREFIX": "Match",
|
||||
"MATCH_SUFFIX": "of the following",
|
||||
"MATCH_ALL": "all",
|
||||
"MATCH_ANY": "any",
|
||||
"GROUP_CONTACT": "Contact attributes",
|
||||
"GROUP_CONVERSATION": "Conversation",
|
||||
"GROUP_CUSTOM": "Custom attributes",
|
||||
"BROWSER_LANGUAGE": "Browser language",
|
||||
"CONVERSATION_LANGUAGE": "Conversation language",
|
||||
"LOGGED_IN": "Logged-in user",
|
||||
"LOGGED_IN_TRUE": "Yes",
|
||||
"LOGGED_IN_FALSE": "No"
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"ALWAYS": {
|
||||
"LABEL": "Anytime",
|
||||
"DESC": "Captain responds to every conversation, regardless of time."
|
||||
},
|
||||
"BUSINESS_HOURS": {
|
||||
"LABEL": "During business hours",
|
||||
"DESC": "Captain responds only within each inbox's business hours."
|
||||
},
|
||||
"OUTSIDE_BUSINESS_HOURS": {
|
||||
"LABEL": "Outside business hours",
|
||||
"DESC": "Captain responds only when each inbox is closed."
|
||||
},
|
||||
"HINT": "Inboxes without business hours configured are always covered."
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
@@ -548,6 +581,14 @@
|
||||
"TITLE": "System settings",
|
||||
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human."
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"TITLE": "Audience",
|
||||
"DESCRIPTION": "Limit which customers this assistant responds to. Leave empty to respond to everyone."
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"TITLE": "Schedule",
|
||||
"DESCRIPTION": "Choose when Captain replies. Outside this window, conversations are handed to your team."
|
||||
},
|
||||
"CONTROL_ITEMS": {
|
||||
"TITLE": "The Fun Stuff",
|
||||
"DESCRIPTION": "Add more control to the assistant. (a bit more visual like a story : Query guardrail → scenarios → output) Nudges user to actually utilise these.",
|
||||
|
||||
+128
-68
@@ -12,7 +12,8 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import AssistantBasicSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantBasicSettingsForm.vue';
|
||||
import AssistantSystemSettingsForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue';
|
||||
import AssistantControlItems from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantControlItems.vue';
|
||||
import AssistantAudienceForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue';
|
||||
import AssistantScheduleForm from 'dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -35,29 +36,56 @@ const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](assistantId.value)
|
||||
);
|
||||
|
||||
const controlItems = computed(() => {
|
||||
return [
|
||||
const activeSection = ref('basic');
|
||||
|
||||
const navItems = computed(() => {
|
||||
const items = [
|
||||
{
|
||||
name: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE'
|
||||
),
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
|
||||
),
|
||||
routeName: 'captain_assistants_guardrails_index',
|
||||
id: 'basic',
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE'),
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
|
||||
),
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
|
||||
),
|
||||
routeName: 'captain_assistants_guidelines_index',
|
||||
id: 'system',
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE'),
|
||||
},
|
||||
{ id: 'audience', label: t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE') },
|
||||
{ id: 'schedule', label: t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.TITLE') },
|
||||
];
|
||||
|
||||
if (isCaptainV2Enabled.value) {
|
||||
items.push(
|
||||
{
|
||||
routeName: 'captain_assistants_guardrails_index',
|
||||
label: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.TITLE'
|
||||
),
|
||||
},
|
||||
{
|
||||
routeName: 'captain_assistants_guidelines_index',
|
||||
label: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.TITLE'
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const handleNavClick = item => {
|
||||
if (item.routeName) {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistantId.value,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
activeSection.value = item.id;
|
||||
};
|
||||
|
||||
const handleSubmit = async updatedAssistant => {
|
||||
try {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
@@ -107,18 +135,30 @@ const handleDeleteSuccess = () => {
|
||||
:is-fetching="isFetching"
|
||||
:show-pagination-footer="false"
|
||||
:show-know-more="false"
|
||||
:class="{
|
||||
'[&>header>div]:max-w-[80rem] [&>main>div]:max-w-[80rem]':
|
||||
isCaptainV2Enabled,
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div
|
||||
class="gap-6 lg:gap-16 pb-8"
|
||||
:class="{ 'grid grid-cols-2': isCaptainV2Enabled }"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex gap-8 pb-8">
|
||||
<nav
|
||||
class="sticky self-start flex flex-col flex-shrink-0 w-48 gap-1 top-0"
|
||||
>
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.id ?? item.routeName"
|
||||
type="button"
|
||||
class="px-3 py-2 text-sm text-left rounded-lg transition-colors"
|
||||
:class="
|
||||
activeSection === item.id
|
||||
? 'bg-n-alpha-2 text-n-slate-12 font-medium'
|
||||
: 'text-n-slate-11 hover:bg-n-alpha-1'
|
||||
"
|
||||
@click="handleNavClick(item)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col flex-1 min-w-0 gap-6">
|
||||
<section v-if="activeSection === 'basic'" class="flex flex-col gap-6">
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
|
||||
:description="
|
||||
@@ -129,9 +169,35 @@ const handleDeleteSuccess = () => {
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
<span class="h-px w-full bg-n-weak mt-2" />
|
||||
<div class="flex flex-col gap-6">
|
||||
<span class="w-full h-px mt-2 bg-n-weak" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
|
||||
assistantName: assistant.name,
|
||||
})
|
||||
"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeSection === 'system'"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
|
||||
:description="
|
||||
@@ -142,45 +208,39 @@ const handleDeleteSuccess = () => {
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
<span class="h-px w-full bg-n-weak mt-2" />
|
||||
<div class="flex items-end justify-between w-full gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-n-slate-12 text-base font-medium">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.TITLE') }}
|
||||
</h6>
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.DELETE.BUTTON_TEXT', {
|
||||
assistantName: assistant.name,
|
||||
})
|
||||
"
|
||||
color="ruby"
|
||||
class="max-w-56 !w-fit"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isCaptainV2Enabled" class="flex flex-col gap-6">
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.TITLE')"
|
||||
:description="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col gap-6">
|
||||
<AssistantControlItems
|
||||
v-for="item in controlItems"
|
||||
:key="item.name"
|
||||
:control-item="item"
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeSection === 'audience'"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.TITLE')"
|
||||
:description="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.AUDIENCE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<AssistantAudienceForm
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="activeSection === 'schedule'"
|
||||
class="flex flex-col gap-6"
|
||||
>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.TITLE')"
|
||||
:description="
|
||||
t('CAPTAIN.ASSISTANTS.SETTINGS.SCHEDULE.DESCRIPTION')
|
||||
"
|
||||
/>
|
||||
<AssistantScheduleForm
|
||||
:assistant="assistant"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user