diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue new file mode 100644 index 000000000..26ce5b558 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantAudienceForm.vue @@ -0,0 +1,147 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue new file mode 100644 index 000000000..3e14455e1 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantScheduleForm.vue @@ -0,0 +1,66 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue new file mode 100644 index 000000000..52fb48fe5 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/AudienceGroup.vue @@ -0,0 +1,167 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js new file mode 100644 index 000000000..4ba27814c --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/audience/useAudienceFilterTypes.js @@ -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 }} + */ +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 }; +} diff --git a/app/javascript/dashboard/components-next/filter/ConditionRow.vue b/app/javascript/dashboard/components-next/filter/ConditionRow.vue index 30709babd..e28c744e0 100644 --- a/app/javascript/dashboard/components-next/filter/ConditionRow.vue +++ b/app/javascript/dashboard/components-next/filter/ConditionRow.vue @@ -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" /> 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(); }; @@ -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)" /> @@ -91,8 +115,17 @@ const updateSelected = newValue => { :class="dropdownPosition" strong > +
+ + +
-