Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,115 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useExpandableContent } from 'shared/composables/useExpandableContent';
const props = defineProps({
author: {
type: String,
default: '',
},
message: {
type: Object,
default: () => ({}),
},
searchTerm: {
type: String,
default: '',
},
});
const { t } = useI18n();
const { highlightContent } = useMessageFormatter();
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
useExpandableContent();
const messageContent = computed(() => {
// We perform search on either content or email subject or transcribed text
if (props.message.content) {
return props.message.content;
}
const { content_attributes = {} } = props.message;
const { email = {} } = content_attributes || {};
if (email.subject) {
return email.subject;
}
const audioAttachment = props.message.attachments?.find(
attachment => attachment.file_type === 'audio'
);
return audioAttachment?.transcribed_text || '';
});
const escapeHtml = html => {
const wrapper = document.createElement('p');
wrapper.textContent = html;
return wrapper.textContent;
};
const highlightedContent = computed(() => {
const content = messageContent.value || '';
const escapedText = escapeHtml(content);
return highlightContent(
escapedText,
props.searchTerm,
'searchkey--highlight'
);
});
const authorText = computed(() => {
const author = props.author || '';
const wroteText = t('SEARCH.WROTE');
return author ? `${author} ${wroteText} ` : '';
});
</script>
<template>
<div
ref="contentElement"
class="break-words grid items-center text-n-slate-11 text-sm leading-relaxed"
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
>
<div
class="min-w-0"
:class="{
'overflow-hidden whitespace-nowrap text-ellipsis': showReadMore,
}"
>
<span v-if="authorText" class="text-n-slate-11 font-medium leading-4">{{
authorText
}}</span>
<span
v-dompurify-html="highlightedContent"
class="message-content text-n-slate-12 [&_.searchkey--highlight]:text-n-slate-12 [&_.searchkey--highlight]:font-semibold"
/>
<button
v-if="showReadLess"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
@click.prevent="toggleExpanded(false)"
>
{{ t('SEARCH.READ_LESS') }}
</button>
</div>
<button
v-if="showReadMore"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
@click.prevent="toggleExpanded(true)"
>
{{ t('SEARCH.READ_MORE') }}
</button>
</div>
</template>
<style scoped lang="scss">
.message-content::v-deep p {
@apply inline;
margin: 0;
}
.message-content::v-deep br {
display: none;
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['selectSearch', 'clearAll']);
const MAX_RECENT_SEARCHES = 3;
const recentSearches = ref([]);
const loadRecentSearches = () => {
const stored = LocalStorage.get(LOCAL_STORAGE_KEYS.RECENT_SEARCHES) || [];
recentSearches.value = Array.isArray(stored) ? stored : [];
};
const saveRecentSearches = () => {
LocalStorage.set(LOCAL_STORAGE_KEYS.RECENT_SEARCHES, recentSearches.value);
};
const addRecentSearch = query => {
if (!query || query.trim().length < 2) return;
const trimmedQuery = query.trim();
const existingIndex = recentSearches.value.findIndex(
search => search.toLowerCase() === trimmedQuery.toLowerCase()
);
if (existingIndex !== -1) {
recentSearches.value.splice(existingIndex, 1);
}
recentSearches.value.unshift(trimmedQuery);
if (recentSearches.value.length > MAX_RECENT_SEARCHES) {
recentSearches.value = recentSearches.value.slice(0, MAX_RECENT_SEARCHES);
}
saveRecentSearches();
};
const clearRecentSearches = () => {
recentSearches.value = [];
LocalStorage.remove(LOCAL_STORAGE_KEYS.RECENT_SEARCHES);
};
const hasRecentSearches = computed(() => recentSearches.value?.length > 0);
const onSelectSearch = query => {
emit('selectSearch', query);
};
const onClearAll = () => {
clearRecentSearches();
emit('clearAll');
};
defineExpose({
addRecentSearch,
loadRecentSearches,
});
onMounted(() => {
loadRecentSearches();
});
</script>
<template>
<div v-if="hasRecentSearches" class="px-4 pb-4 w-full pt-2">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2.5">
<Icon icon="i-lucide-rotate-ccw" class="text-n-slate-10 size-4" />
<h3 class="text-base font-medium text-n-slate-10">
{{ $t('SEARCH.RECENT_SEARCHES') }}
</h3>
</div>
<Button
type="button"
xs
slate
ghost
class="!text-n-slate-10 hover:!text-n-slate-12"
@mousedown.prevent
@click="onClearAll"
>
{{ $t('SEARCH.CLEAR_ALL') }}
</Button>
</div>
<div class="flex flex-col gap-4 items-start">
<button
v-for="(search, index) in recentSearches"
:key="search"
type="button"
class="w-full flex items-center gap-2.5 text-left text-base text-n-slate-12 rounded-lg transition-all duration-150 group p-0"
@mousedown.prevent
@click="onSelectSearch(search)"
>
<Icon
icon="i-lucide-search"
class="text-n-slate-10 group-hover:text-n-slate-11 transition-colors duration-150 size-4"
/>
<span class="flex-1 truncate">{{ search }}</span>
<span
class="text-xs text-n-slate-8 opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
{{ index === 0 ? $t('SEARCH.MOST_RECENT') : '' }}
</span>
</button>
</div>
</div>
<template v-else />
</template>

View File

@@ -0,0 +1,239 @@
<script setup>
import { ref, computed, defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { debounce } from '@chatwoot/utils';
import { useMapGetter } from 'dashboard/composables/store.js';
import { searchContacts } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { fetchContactDetails } from '../helpers/searchHelper';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: { type: String, required: true },
});
const emit = defineEmits(['change']);
const FROM_TYPE = {
CONTACT: 'contact',
AGENT: 'agent',
};
const MENU_ACTIONS_SELECT = 'select';
const modelValue = defineModel({ type: String, default: null });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const searchQuery = ref('');
const searchedContacts = ref([]);
const isSearching = ref(false);
const selectedContact = ref(null);
const agentsList = useMapGetter('agents/getVerifiedAgents');
const createMenuItem = (item, type, isAgent = false) => {
const transformed = useCamelCase(item, { deep: true });
const value = `${type}:${transformed.id}`;
return {
label: transformed.name,
value,
action: MENU_ACTIONS_SELECT,
type,
thumbnail: {
name: transformed.name,
src: isAgent ? transformed.avatarUrl : transformed.thumbnail,
},
...(isAgent
? {}
: { description: transformed.email || transformed.phoneNumber }),
isSelected: modelValue.value === value,
};
};
const agentsSection = computed(() => {
const agents =
agentsList.value?.map(agent =>
createMenuItem(agent, FROM_TYPE.AGENT, true)
) || [];
return searchQuery.value
? agents.filter(agent =>
agent.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
: agents;
});
const contactsSection = computed(
() =>
searchedContacts.value?.map(contact =>
createMenuItem(contact, FROM_TYPE.CONTACT)
) || []
);
const menuSections = computed(() => [
{
title: t('SEARCH.FILTERS.CONTACTS'),
items: contactsSection.value,
isLoading: isSearching.value,
emptyState: t('SEARCH.FILTERS.NO_CONTACTS'),
},
{
title: t('SEARCH.FILTERS.AGENTS'),
items: agentsSection.value,
emptyState: t('SEARCH.FILTERS.NO_AGENTS'),
},
]);
const selectedLabel = computed(() => {
if (!modelValue.value) return props.label;
const [type, id] = modelValue.value.split(':');
const numericId = Number(id);
if (type === FROM_TYPE.CONTACT) {
if (selectedContact.value?.id === numericId) {
return `${props.label}: ${selectedContact.value.name}`;
}
const contact = searchedContacts.value?.find(c => c.id === numericId);
if (contact) return `${props.label}: ${contact.name}`;
} else if (type === FROM_TYPE.AGENT) {
const agent = agentsList.value?.find(a => a.id === numericId);
if (agent) return `${props.label}: ${agent.name}`;
}
return `${props.label}: ${numericId}`;
});
const debouncedSearch = debounce(async query => {
if (!query) {
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
isSearching.value = false;
return;
}
try {
const contacts = await searchContacts({
keys: ['name', 'email', 'phone_number'],
query,
});
// Add selected contact to top if not already in results
const allContacts = selectedContact.value
? [
selectedContact.value,
...contacts.filter(c => c.id !== selectedContact.value.id),
]
: contacts;
searchedContacts.value = allContacts;
} catch {
// Ignore error
} finally {
isSearching.value = false;
}
}, 300);
const performSearch = query => {
searchQuery.value = query;
if (query) {
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
isSearching.value = true;
}
debouncedSearch(query);
};
const onToggleDropdown = () => {
if (!showDropdown.value) {
// Reset search when opening dropdown
searchQuery.value = '';
searchedContacts.value = selectedContact.value
? [selectedContact.value]
: [];
}
toggleDropdown();
};
const handleAction = item => {
if (modelValue.value === item.value) {
modelValue.value = null;
selectedContact.value = null;
} else {
modelValue.value = item.value;
if (item.type === FROM_TYPE.CONTACT) {
const [, id] = item.value.split(':');
selectedContact.value = {
id: Number(id),
name: item.label,
thumbnail: item.thumbnail?.src,
};
} else {
selectedContact.value = null;
}
}
toggleDropdown(false);
emit('change');
};
const resolveContactName = async () => {
if (!modelValue.value) return;
const [type, id] = modelValue.value.split(':');
if (type !== FROM_TYPE.CONTACT) return;
const numericId = Number(id);
if (selectedContact.value?.id === numericId) return;
const contact = await fetchContactDetails(numericId);
if (contact) {
selectedContact.value = {
id: contact.id,
name: contact.name,
thumbnail: contact.thumbnail,
};
if (!searchedContacts.value.some(c => c.id === contact.id)) {
searchedContacts.value.push(selectedContact.value);
}
}
};
watch(() => modelValue.value, resolveContactName, { immediate: true });
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
:variant="showDropdown ? 'faded' : 'ghost'"
slate
:label="selectedLabel"
trailing-icon
icon="i-lucide-chevron-down"
class="!px-2 max-w-full"
@click="onToggleDropdown"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
show-search
disable-local-filtering
:is-searching="isSearching"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64 max-h-80 overflow-y-auto"
@search="performSearch"
@action="handleAction"
/>
</div>
</template>

View File

@@ -0,0 +1,271 @@
<script setup>
import { computed, ref, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import {
subDays,
subMonths,
subYears,
startOfDay,
endOfDay,
format,
getUnixTime,
fromUnixTime,
} from 'date-fns';
import { DATE_RANGE_TYPES } from '../helpers/searchHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['change']);
const modelValue = defineModel({
type: Object,
default: () => ({ type: null, from: null, to: null }),
});
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const customFrom = ref('');
const customTo = ref('');
const rangeType = ref(DATE_RANGE_TYPES.BETWEEN);
// Calculate min date (90 days ago) for date inputs
const minDate = computed(() => format(subDays(new Date(), 90), 'yyyy-MM-dd'));
const maxDate = computed(() => format(new Date(), 'yyyy-MM-dd'));
// Check if both custom date inputs have values
const hasCustomDates = computed(() => customFrom.value && customTo.value);
const DATE_FILTER_ACTIONS = {
PRESET: 'preset',
SELECT: 'select',
};
const PRESET_RANGES = computed(() => [
{
label: t('SEARCH.DATE_RANGE.LAST_7_DAYS'),
value: DATE_RANGE_TYPES.LAST_7_DAYS,
days: 7,
},
{
label: t('SEARCH.DATE_RANGE.LAST_30_DAYS'),
value: DATE_RANGE_TYPES.LAST_30_DAYS,
days: 30,
},
{
label: t('SEARCH.DATE_RANGE.LAST_60_DAYS'),
value: DATE_RANGE_TYPES.LAST_60_DAYS,
days: 60,
},
{
label: t('SEARCH.DATE_RANGE.LAST_90_DAYS'),
value: DATE_RANGE_TYPES.LAST_90_DAYS,
days: 90,
},
]);
const computeDateRange = config => {
const end = endOfDay(new Date());
let start;
if (config.days) {
start = startOfDay(subDays(end, config.days));
} else if (config.months) {
start = startOfDay(subMonths(end, config.months));
} else {
start = startOfDay(subYears(end, config.years));
}
return { type: config.value, from: getUnixTime(start), to: getUnixTime(end) };
};
const selectedValue = computed(() => {
const { from, to, type } = modelValue.value || {};
if (!from && !to && !type) return '';
return type || DATE_RANGE_TYPES.CUSTOM;
});
const menuItems = computed(() =>
PRESET_RANGES.value.map(item => ({
...item,
action: DATE_FILTER_ACTIONS.PRESET,
isSelected: selectedValue.value === item.value,
}))
);
const applySelection = ({ type, from, to }) => {
const newValue = { type, from, to };
modelValue.value = newValue;
emit('change', newValue);
};
const clearFilter = () => {
applySelection({ type: null, from: null, to: null });
customFrom.value = '';
customTo.value = '';
toggleDropdown(false);
};
const handlePresetAction = item => {
if (selectedValue.value === item.value) {
clearFilter();
return;
}
customFrom.value = '';
customTo.value = '';
applySelection(computeDateRange(item));
toggleDropdown(false);
};
const applyCustomRange = () => {
const customFromDate = customFrom.value
? startOfDay(new Date(customFrom.value))
: null;
const customToDate = customTo.value
? endOfDay(new Date(customTo.value))
: null;
// Only BETWEEN mode - require both dates
if (customFromDate && customToDate) {
applySelection({
type: DATE_RANGE_TYPES.BETWEEN,
from: getUnixTime(customFromDate),
to: getUnixTime(customToDate),
});
toggleDropdown(false);
}
};
const clearCustomRange = () => {
customFrom.value = '';
customTo.value = '';
};
const formatDate = timestamp => format(fromUnixTime(timestamp), 'MMM d, yyyy'); // (e.g., "Jan 15, 2024")
const selectedLabel = computed(() => {
const prefix = t('SEARCH.DATE_RANGE.TIME_RANGE');
if (!selectedValue.value) return prefix;
// Check if it's a preset
const preset = PRESET_RANGES.value.find(p => p.value === selectedValue.value);
if (preset) return `${prefix}: ${preset.label}`;
// Custom range - only BETWEEN mode with both dates
const { from, to } = modelValue.value;
if (from && to) return `${prefix}: ${formatDate(from)} - ${formatDate(to)}`;
return `${prefix}: ${t('SEARCH.DATE_RANGE.CUSTOM_RANGE')}`;
});
const CUSTOM_RANGE_TYPES = [DATE_RANGE_TYPES.BETWEEN, DATE_RANGE_TYPES.CUSTOM];
const onToggleDropdown = () => {
if (!showDropdown.value) {
const { type, from, to } = modelValue.value || {};
rangeType.value = CUSTOM_RANGE_TYPES.includes(type)
? type
: DATE_RANGE_TYPES.BETWEEN;
if (CUSTOM_RANGE_TYPES.includes(type)) {
try {
customFrom.value = from ? format(fromUnixTime(from), 'yyyy-MM-dd') : '';
customTo.value = to ? format(fromUnixTime(to), 'yyyy-MM-dd') : '';
} catch {
customFrom.value = '';
customTo.value = '';
}
} else {
customFrom.value = '';
customTo.value = '';
}
}
toggleDropdown();
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
slate
:variant="showDropdown ? 'faded' : 'solid'"
:label="selectedLabel"
class="group-hover:bg-n-alpha-2 max-w-full"
trailing-icon
icon="i-lucide-chevron-down"
@click="onToggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:left-0 rtl:right-0 top-full w-64"
@action="handlePresetAction"
>
<template #footer>
<div class="h-px bg-n-strong" />
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 px-1 h-9">
<span class="text-sm text-n-slate-11">
{{ t('SEARCH.DATE_RANGE.CUSTOM_RANGE') }}
</span>
<span class="text-sm text-n-slate-12">
{{ t('SEARCH.DATE_RANGE.CREATED_BETWEEN') }}
</span>
</div>
<input
v-model="customFrom"
type="date"
:min="minDate"
:max="customTo || maxDate"
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
/>
<div class="flex items-center gap-3 h-5 px-1">
<div class="flex-1 h-px bg-n-weak" />
<span class="text-sm text-n-slate-11">
{{ t('SEARCH.DATE_RANGE.AND') }}
</span>
<div class="flex-1 h-px bg-n-weak" />
</div>
<input
v-model="customTo"
type="date"
:min="customFrom || minDate"
:max="maxDate"
class="!w-full !mb-0 !rounded-lg !bg-n-alpha-black2 !outline-n-strong -outline-offset-1 !px-3 !py-2 !text-sm text-n-slate-12 !h-8"
/>
<div class="flex items-center gap-2 mt-2">
<Button
sm
slate
faded
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
:disabled="!hasCustomDates"
class="flex-1 justify-center"
@click="clearCustomRange"
/>
<Button
sm
solid
color="blue"
:label="t('SEARCH.DATE_RANGE.APPLY')"
:disabled="!hasCustomDates"
class="flex-1 justify-center"
@click="applyCustomRange"
/>
</div>
</div>
</template>
</DropdownMenu>
</div>
</template>

View File

@@ -0,0 +1,104 @@
<script setup>
import { computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import SearchDateRangeSelector from './SearchDateRangeSelector.vue';
import SearchContactAgentSelector from './SearchContactAgentSelector.vue';
import SearchInboxSelector from './SearchInboxSelector.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['updateFilters']);
const { t } = useI18n();
const filters = defineModel({
type: Object,
default: () => ({
from: null, // Contact id and Agent id
in: null, // Inbox id
dateRange: { type: null, from: null, to: null },
}),
});
const hasActiveFilters = computed(
() =>
filters.value.from ||
filters.value.in ||
filters.value.dateRange?.type ||
filters.value.dateRange?.from ||
filters.value.dateRange?.to
);
const onFilterChange = () => {
emit('updateFilters', filters.value);
};
const clearAllFilters = () => {
filters.value = {
from: null,
in: null,
dateRange: { type: null, from: null, to: null },
};
onFilterChange();
};
</script>
<template>
<div
class="flex flex-col lg:flex-row items-start lg:items-center gap-3 p-4 w-full min-w-0"
>
<div class="flex items-center gap-3 min-w-0 max-w-full">
<Button
v-if="hasActiveFilters"
sm
slate
solid
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
icon="i-lucide-x"
class="flex-shrink-0 lg:hidden"
@click="clearAllFilters"
/>
<SearchDateRangeSelector
v-model="filters.dateRange"
class="min-w-0 max-w-full"
@change="onFilterChange"
/>
</div>
<div class="w-px h-4 bg-n-weak flex-shrink-0 hidden lg:block" />
<div class="flex items-center gap-1.5 min-w-0 flex-1 max-w-full">
<span class="text-sm text-n-slate-10 flex-shrink-0 whitespace-nowrap">
{{ t('SEARCH.FILTERS.FILTER_MESSAGE') }}
</span>
<div class="min-w-0">
<SearchContactAgentSelector
v-model="filters.from"
:label="$t('SEARCH.FILTERS.FROM')"
@change="onFilterChange"
/>
</div>
<div class="w-px h-3 bg-n-weak rounded-lg flex-shrink-0" />
<div class="min-w-0">
<SearchInboxSelector
v-model="filters.in"
:label="$t('SEARCH.FILTERS.IN')"
@change="onFilterChange"
/>
</div>
</div>
<Button
v-if="hasActiveFilters"
sm
slate
solid
:label="t('SEARCH.DATE_RANGE.CLEAR_FILTER')"
icon="i-lucide-x"
class="flex-shrink-0 hidden lg:inline-flex"
@click="clearAllFilters"
/>
</div>
</template>

View File

@@ -0,0 +1,69 @@
<script setup>
import { ref, watch, useTemplateRef, defineModel } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { ROLES } from 'dashboard/constants/permissions';
import SearchInput from './SearchInput.vue';
import SearchFilters from './SearchFilters.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
initialQuery: { type: String, default: '' },
});
const emit = defineEmits(['search', 'filterChange']);
const filters = defineModel('filters', { type: Object, default: () => ({}) });
const searchInputRef = useTemplateRef('searchInputRef');
const searchQuery = ref(props.initialQuery);
const onSearch = query => {
if (query?.trim() && searchInputRef.value) {
searchInputRef.value.addToRecentSearches(query.trim());
}
emit('search', query);
};
const onSelectRecentSearch = query => {
searchQuery.value = query;
onSearch(query);
};
watch(
() => props.initialQuery,
newValue => {
if (searchQuery.value !== newValue) {
searchQuery.value = newValue;
}
},
{ immediate: true }
);
</script>
<template>
<div class="flex flex-col gap-2">
<SearchInput
ref="searchInputRef"
v-model="searchQuery"
@search="onSearch"
@select-recent-search="onSelectRecentSearch"
>
<Policy
:permissions="ROLES"
:installation-types="[
INSTALLATION_TYPES.ENTERPRISE,
INSTALLATION_TYPES.CLOUD,
]"
:feature-flag="FEATURE_FLAGS.ADVANCED_SEARCH"
class="w-full"
>
<SearchFilters
v-model="filters"
@update-filters="$emit('filterChange', $event)"
/>
</Policy>
</SearchInput>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import { ref, computed, defineModel } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
label: {
type: String,
required: true,
},
});
const emit = defineEmits(['change']);
const modelValue = defineModel({
type: [String, Number],
default: null,
});
const MENU_ITEM_TYPES = {
INBOX: 'inbox',
};
const MENU_ACTIONS = {
SELECT: 'select',
};
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const searchQuery = ref('');
const inboxesList = useMapGetter('inboxes/getInboxes');
const inboxesSection = computed(() => {
const inboxes = inboxesList.value?.map(inbox => {
const transformedInbox = useCamelCase(inbox, { deep: true });
return {
label: transformedInbox.name,
value: transformedInbox.id,
action: MENU_ACTIONS.SELECT,
type: MENU_ITEM_TYPES.INBOX,
thumbnail: {
name: transformedInbox.name,
src: transformedInbox.avatarUrl,
},
isSelected: modelValue.value === transformedInbox.id,
};
});
if (!searchQuery.value) return inboxes;
return inboxes.filter(inbox =>
inbox.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const menuSections = computed(() => {
return [
{
title: t('SEARCH.FILTERS.INBOXES'),
items: inboxesSection.value,
emptyState: t('SEARCH.FILTERS.NO_INBOXES'),
},
];
});
const selectedLabel = computed(() => {
if (!modelValue.value) return props.label;
// Find the selected inbox
const inbox = inboxesList.value?.find(i => i.id === modelValue.value);
if (inbox) return `${props.label}: ${inbox.name}`;
return `${props.label}: ${modelValue.value}`;
});
const handleAction = item => {
if (modelValue.value === item.value) {
modelValue.value = null;
} else {
modelValue.value = item.value;
}
toggleDropdown(false);
emit('change');
};
const onToggleDropdown = () => {
if (!showDropdown.value) {
searchQuery.value = '';
}
toggleDropdown();
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group min-w-0 max-w-full"
>
<Button
sm
:variant="showDropdown ? 'faded' : 'ghost'"
slate
:label="selectedLabel"
trailing-icon
icon="i-lucide-chevron-down"
class="!px-2 max-w-full"
@click="onToggleDropdown"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
show-search
disable-local-filtering
class="mt-1 ltr:right-0 rtl:left-0 top-full w-64 max-h-80 overflow-y-auto"
@search="searchQuery = $event"
@action="handleAction"
/>
</div>
</template>

View File

@@ -0,0 +1,136 @@
<script setup>
import { ref, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { debounce } from '@chatwoot/utils';
import RecentSearches from './RecentSearches.vue';
const emit = defineEmits(['search', 'selectRecentSearch']);
const searchQuery = defineModel({
type: String,
default: '',
});
const isInputFocused = ref(false);
const showRecentSearches = ref(false);
const searchInput = useTemplateRef('searchInput');
const recentSearchesRef = useTemplateRef('recentSearchesRef');
const handler = e => {
if (e.key === '/' && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
searchInput.value.focus();
} else if (e.key === 'Escape' && document.activeElement.tagName === 'INPUT') {
e.preventDefault();
searchInput.value.blur();
}
};
const debouncedEmit = debounce(
value =>
emit('search', value.length > 1 || value.match(/^[0-9]+$/) ? value : ''),
500
);
const onInput = () => {
debouncedEmit(searchQuery.value);
if (searchQuery.value.trim()) {
showRecentSearches.value = false;
} else if (isInputFocused.value) {
showRecentSearches.value = true;
}
};
const onFocus = () => {
isInputFocused.value = true;
if (!searchQuery.value.trim()) {
showRecentSearches.value = true;
}
};
const onBlur = () => {
isInputFocused.value = false;
showRecentSearches.value = false;
};
const onSelectRecentSearch = query => {
searchQuery.value = query;
emit('selectRecentSearch', query);
showRecentSearches.value = false;
searchInput.value.focus();
};
const addToRecentSearches = query => {
if (recentSearchesRef.value) {
recentSearchesRef.value.addRecentSearch(query);
}
};
defineExpose({
addToRecentSearches,
});
onMounted(() => {
searchInput.value.focus();
document.addEventListener('keydown', handler);
});
onUnmounted(() => {
document.removeEventListener('keydown', handler);
});
</script>
<template>
<div
class="rounded-xl transition-[border-bottom] duration-[0.2s] ease-[ease-in-out] relative flex items-start flex-col border border-solid bg-n-solid-1 divide-y divide-n-strong"
:class="{
'border-n-brand': isInputFocused,
'border-n-strong': !isInputFocused,
}"
>
<div class="flex items-center w-full h-[3.25rem] px-4 gap-2">
<div class="flex items-center">
<fluent-icon
icon="search"
class="icon"
aria-hidden="true"
:class="{
'text-n-blue-11': isInputFocused,
'text-n-slate-10': !isInputFocused,
}"
/>
</div>
<input
ref="searchInput"
v-model="searchQuery"
type="search"
class="reset-base outline-none w-full m-0 bg-transparent border-transparent shadow-none text-n-slate-12 dark:text-n-slate-12 active:border-transparent active:shadow-none hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none placeholder:text-n-slate-10 text-base"
:placeholder="$t('SEARCH.INPUT_PLACEHOLDER')"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
/>
<span class="text-sm text-n-slate-10 flex-shrink-0">
{{ $t('SEARCH.PLACEHOLDER_KEYBINDING') }}
</span>
</div>
<slot />
<div
class="transition-all duration-200 ease-out grid overflow-hidden w-full !border-t-0"
:class="
showRecentSearches
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
"
>
<div class="overflow-hidden w-full">
<RecentSearches
ref="recentSearchesRef"
@select-search="onSelectRecentSearch"
@clear-all="showRecentSearches = false"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { ARTICLE_STATUSES } from 'dashboard/helper/portalHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter';
const props = defineProps({
id: { type: [String, Number], default: 0 },
title: { type: String, default: '' },
description: { type: String, default: '' },
category: { type: String, default: '' },
locale: { type: String, default: '' },
content: { type: String, default: '' },
portalSlug: { type: String, required: true },
accountId: { type: [String, Number], default: 0 },
status: { type: String, default: '' },
updatedAt: { type: Number, default: 0 },
});
const MAX_LENGTH = 300;
const navigateTo = computed(() => {
return frontendURL(
`accounts/${props.accountId}/portals/${props.portalSlug}/${props.locale}/articles/edit/${props.id}`
);
});
const updatedAtTime = computed(() => {
if (!props.updatedAt) return '';
return dynamicTime(props.updatedAt);
});
const truncatedContent = computed(() => {
if (!props.content) return props.description || '';
// Use MessageFormatter to properly convert markdown to plain text
const formatter = new MessageFormatter(props.content);
const plainText = formatter.plainText.trim();
return plainText.length > MAX_LENGTH
? `${plainText.substring(0, MAX_LENGTH)}...`
: plainText;
});
const statusTextColor = computed(() => {
switch (props.status) {
case ARTICLE_STATUSES.ARCHIVED:
return 'text-n-slate-12';
case ARTICLE_STATUSES.DRAFT:
return 'text-n-amber-11';
default:
return 'text-n-teal-11';
}
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:pt-4 [&>div]:pb-5 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div class="min-w-0 flex-1 flex flex-col items-start gap-2 w-full">
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
<div class="flex items-center gap-2">
<h5
class="text-sm font-medium leading-4 truncate min-w-0 text-n-slate-12"
>
{{ title }}
</h5>
<div v-if="category" class="w-px h-4 bg-n-strong mx-2" />
<span
v-if="category"
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-1.5 h-6 text-n-slate-12"
>
{{ category }}
</span>
<span
v-if="status"
class="text-xs inline-flex items-center font-medium rounded-md whitespace-nowrap capitalize bg-n-alpha-2 px-2 h-6"
:class="statusTextColor"
>
{{ status }}
</span>
</div>
<span
v-if="updatedAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ updatedAtTime }}
</span>
</div>
<p
v-if="truncatedContent"
class="text-sm leading-6 text-n-slate-11 line-clamp-2"
>
{{ truncatedContent }}
</p>
</div>
</CardLayout>
</router-link>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultArticleItem from './SearchResultArticleItem.vue';
defineProps({
articles: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
<SearchResultSection
:title="$t('SEARCH.SECTION.ARTICLES')"
:empty="!articles.length"
:query="query"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="articles.length" class="space-y-3 list-none">
<li v-for="article in articles" :key="article.id">
<SearchResultArticleItem
:id="article.id"
:title="article.title"
:description="article.description"
:content="article.content"
:portal-slug="article.portalSlug"
:locale="article.locale"
:account-id="accountId"
:category="article.categoryName"
:status="article.status"
:updated-at="article.updatedAt"
/>
</li>
</ul>
</SearchResultSection>
</template>

View File

@@ -0,0 +1,154 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import countries from 'shared/constants/countries';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
const props = defineProps({
id: {
type: [String, Number],
default: 0,
},
email: {
type: String,
default: '',
},
phone: {
type: String,
default: '',
},
name: {
type: String,
default: '',
},
thumbnail: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: 0,
},
additionalAttributes: {
type: Object,
default: () => ({}),
},
updatedAt: {
type: Number,
default: 0,
},
});
const navigateTo = computed(() => {
return frontendURL(`accounts/${props.accountId}/contacts/${props.id}`);
});
const countriesMap = computed(() => {
return countries.reduce((acc, country) => {
acc[country.code] = country;
acc[country.id] = country;
return acc;
}, {});
});
const updatedAtTime = computed(() => {
if (!props.updatedAt) return '';
return dynamicTime(props.updatedAt);
});
const countryDetails = computed(() => {
const { country, countryCode, city } = props.additionalAttributes;
if (!country && !countryCode) return null;
const activeCountry =
countriesMap.value[country] || countriesMap.value[countryCode];
if (!activeCountry) return null;
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="row"
class="[&>div]:justify-start [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<Avatar
:name="name"
:src="thumbnail"
:size="24"
rounded-full
class="mt-1 flex-shrink-0"
/>
<div class="min-w-0 flex flex-col items-start gap-1.5 w-full">
<div class="flex items-center min-w-0 justify-between gap-2 w-full">
<h5 class="text-sm font-medium truncate min-w-0 text-n-slate-12 py-1">
{{ name }}
</h5>
<span
v-if="updatedAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ $t('SEARCH.UPDATED_AT', { time: updatedAtTime }) }}
</span>
</div>
<div
class="grid items-center gap-3 m-0 text-sm overflow-hidden min-w-0 grid-cols-[minmax(0,max-content)_auto_minmax(0,max-content)_auto_minmax(0,max-content)]"
>
<span
v-if="email"
class="truncate text-n-slate-11 min-w-0"
:title="email"
>
{{ email }}
</span>
<div v-if="email && phone" class="w-px h-3 bg-n-slate-6 rounded" />
<span
v-if="phone"
:title="phone"
class="truncate text-n-slate-11 min-w-0"
>
{{ phone }}
</span>
<div
v-if="(email || phone) && countryDetails"
class="w-px h-3 bg-n-slate-6 rounded"
/>
<span
v-if="countryDetails"
class="truncate text-n-slate-11 flex items-center gap-1 min-w-0"
>
<Flag
:country="countryDetails.countryCode"
class="size-3 shrink-0"
/>
<span class="truncate min-w-0">{{ formattedLocation }}</span>
</span>
</div>
</div>
</CardLayout>
</router-link>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultContactItem from './SearchResultContactItem.vue';
defineProps({
contacts: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
</script>
<template>
<SearchResultSection
:title="$t('SEARCH.SECTION.CONTACTS')"
:empty="!contacts.length"
:query="query"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="contacts.length" class="space-y-3 list-none">
<li v-for="contact in contacts" :key="contact.id">
<SearchResultContactItem
:id="contact.id"
:name="contact.name"
:email="contact.email"
:phone="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:account-id="accountId"
:thumbnail="contact.thumbnail"
:updated-at="contact.lastActivityAt"
/>
</li>
</ul>
</SearchResultSection>
</template>

View File

@@ -0,0 +1,157 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useInbox } from 'dashboard/composables/useInbox';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
id: {
type: Number,
default: 0,
},
inbox: {
type: Object,
default: () => ({}),
},
name: {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
emailSubject: {
type: String,
default: '',
},
});
const { inbox } = useInbox(props.inbox?.id);
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = computed(() => {
if (!props.createdAt) return '';
return dynamicTime(props.createdAt);
});
const infoItems = computed(() => [
{
label: 'SEARCH.FROM',
value: props.name,
show: !!props.name,
},
{
label: 'SEARCH.EMAIL',
value: props.email,
show: !!props.email,
},
{
label: 'SEARCH.EMAIL_SUBJECT',
value: props.emailSubject,
show: !!props.emailSubject,
},
]);
const visibleInfoItems = computed(() =>
infoItems.value.filter(item => item.show)
);
const inboxName = computed(() => props.inbox?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon
icon="i-lucide-hash"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<span class="text-n-slate-12 text-sm leading-4">
{{ id }}
</span>
</div>
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
<div
v-if="inboxIcon"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm leading-4 text-n-slate-12">
{{ inboxName }}
</span>
</div>
</div>
<span
v-if="createdAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<div class="flex flex-wrap gap-x-2 gap-y-1.5 items-center">
<template
v-for="(item, index) in visibleInfoItems"
:key="`info-${index}`"
>
<h5 class="m-0 text-sm min-w-0 text-n-slate-12 truncate">
<span class="text-sm leading-4 font-normal text-n-slate-11">
{{ $t(item.label) + ':' }}
</span>
{{ item.value }}
</h5>
<div
v-if="index < visibleInfoItems.length - 1"
class="w-px h-3 bg-n-strong"
/>
</template>
</div>
<slot />
</CardLayout>
</router-link>
</template>

View File

@@ -0,0 +1,62 @@
<script setup>
import { defineProps, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultSection from './SearchResultSection.vue';
import SearchResultConversationItem from './SearchResultConversationItem.vue';
const props = defineProps({
conversations: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
});
const accountId = useMapGetter('getCurrentAccountId');
const conversationsWithSubject = computed(() => {
return props.conversations.map(conversation => ({
...conversation,
mailSubject: conversation.additionalAttributes?.mailSubject || '',
}));
});
</script>
<template>
<SearchResultSection
:title="$t('SEARCH.SECTION.CONVERSATIONS')"
:empty="!conversations.length"
:query="query"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="conversations.length" class="space-y-3 list-none">
<li
v-for="conversation in conversationsWithSubject"
:key="conversation.id"
>
<SearchResultConversationItem
:id="conversation.id"
:name="conversation.contact.name"
:email="conversation.contact.email"
:account-id="accountId"
:inbox="conversation.inbox"
:created-at="conversation.createdAt"
:email-subject="conversation.mailSubject"
/>
</li>
</ul>
</SearchResultSection>
</template>

View File

@@ -0,0 +1,169 @@
<script setup>
import { computed } from 'vue';
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useInbox } from 'dashboard/composables/useInbox';
import { ATTACHMENT_TYPES } from 'dashboard/components-next/message/constants.js';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import FileChip from 'next/message/chips/File.vue';
import AudioChip from 'next/message/chips/Audio.vue';
import TranscribedText from './TranscribedText.vue';
const props = defineProps({
id: {
type: Number,
default: 0,
},
inboxId: {
type: Number,
default: 0,
},
isPrivate: {
type: Boolean,
default: false,
},
accountId: {
type: [String, Number],
default: '',
},
createdAt: {
type: [String, Date, Number],
default: '',
},
messageId: {
type: Number,
default: 0,
},
attachments: {
type: Array,
default: () => [],
},
});
const { inbox } = useInbox(props.inboxId);
const navigateTo = computed(() => {
const params = {};
if (props.messageId) {
params.messageId = props.messageId;
}
return frontendURL(
`accounts/${props.accountId}/conversations/${props.id}`,
params
);
});
const createdAtTime = computed(() => {
if (!props.createdAt) return '';
return dynamicTime(props.createdAt);
});
const inboxName = computed(() => inbox.value?.name);
const inboxIcon = computed(() => {
if (!inbox.value) return null;
const { channelType, medium } = inbox.value;
return getInboxIconByType(channelType, medium);
});
const fileAttachments = computed(() => {
return props.attachments.filter(
attachment => attachment.fileType !== ATTACHMENT_TYPES.AUDIO
);
});
const audioAttachments = computed(() => {
return props.attachments.filter(
attachment => attachment.fileType === ATTACHMENT_TYPES.AUDIO
);
});
</script>
<template>
<router-link :to="navigateTo">
<CardLayout
layout="col"
class="[&>div]:justify-start [&>div]:gap-2 [&>div]:px-4 [&>div]:py-3 [&>div]:items-start hover:bg-n-slate-2 dark:hover:bg-n-solid-3"
>
<div
class="flex items-center min-w-0 justify-between gap-2 w-full h-7 mb-1"
>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon
icon="i-lucide-hash"
class="flex-shrink-0 text-n-slate-11 size-4"
/>
<span class="text-n-slate-12 text-sm leading-4">
{{ id }}
</span>
</div>
<div v-if="inboxName" class="w-px h-3 bg-n-strong" />
<div v-if="inboxName" class="flex items-center gap-1.5 flex-shrink-0">
<div
v-if="inboxIcon"
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-4"
>
<Icon
:icon="inboxIcon"
class="flex-shrink-0 text-n-slate-11 size-2.5"
/>
</div>
<span class="text-sm leading-4 text-n-slate-12">
{{ inboxName }}
</span>
</div>
<div v-if="isPrivate" class="w-px h-3 bg-n-strong" />
<div
v-if="isPrivate"
class="flex items-center text-n-amber-11 gap-1.5 flex-shrink-0"
>
<Icon icon="i-lucide-lock-keyhole" class="flex-shrink-0 size-3.5" />
<span class="text-sm leading-4">
{{ $t('SEARCH.PRIVATE') }}
</span>
</div>
</div>
<span
v-if="createdAtTime"
class="text-sm font-normal min-w-0 truncate text-n-slate-11"
>
{{ createdAtTime }}
</span>
</div>
<slot />
<div v-if="audioAttachments.length" class="mt-1.5 space-y-4 w-full">
<div
v-for="attachment in audioAttachments"
:key="attachment.id"
class="w-full"
>
<AudioChip
class="bg-n-alpha-2 dark:bg-n-alpha-2 text-n-slate-12"
:attachment="attachment"
:show-transcribed-text="false"
@click.prevent
/>
<div v-if="attachment.transcribedText" class="pt-2">
<TranscribedText :text="attachment.transcribedText" />
</div>
</div>
</div>
<div
v-if="fileAttachments.length"
class="flex gap-2 flex-wrap items-center mt-1.5"
>
<FileChip
v-for="attachment in fileAttachments"
:key="attachment.id"
:attachment="attachment"
class="!h-8"
@click.stop
/>
</div>
</CardLayout>
</router-link>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import SearchResultMessageItem from './SearchResultMessageItem.vue';
import SearchResultSection from './SearchResultSection.vue';
import MessageContent from './MessageContent.vue';
defineProps({
messages: {
type: Array,
default: () => [],
},
query: {
type: String,
default: '',
},
isFetching: {
type: Boolean,
default: false,
},
showTitle: {
type: Boolean,
default: true,
},
});
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const getName = message => {
return message && message.sender && message.sender.name
? message.sender.name
: t('SEARCH.BOT_LABEL');
};
</script>
<template>
<SearchResultSection
:title="$t('SEARCH.SECTION.MESSAGES')"
:empty="!messages.length"
:query="query"
:show-title="showTitle"
:is-fetching="isFetching"
>
<ul v-if="messages.length" class="space-y-3 list-none">
<li v-for="message in messages" :key="message.id">
<SearchResultMessageItem
:id="message.conversationId"
:account-id="accountId"
:inbox-id="message.inboxId"
:created-at="message.createdAt"
:message-id="message.id"
:is-private="message.private"
:attachments="message.attachments"
>
<MessageContent
:author="getName(message)"
:message="message"
:search-term="query"
/>
</SearchResultMessageItem>
</li>
</ul>
</SearchResultSection>
</template>

View File

@@ -0,0 +1,58 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
title: {
type: String,
default: '',
},
empty: {
type: Boolean,
default: false,
},
query: {
type: String,
default: '',
},
showTitle: {
type: Boolean,
default: true,
},
isFetching: {
type: Boolean,
default: true,
},
});
const titleCase = computed(() => props.title.toLowerCase());
</script>
<template>
<section class="mx-0 mb-3">
<div
v-if="showTitle"
class="sticky top-0 pt-2 py-3 z-20 bg-gradient-to-b from-n-surface-1 from-80% to-transparent mb-3 -mx-1.5 px-1.5"
>
<h3 class="text-sm text-n-slate-11">{{ title }}</h3>
</div>
<slot />
<woot-loading-state
v-if="isFetching"
:message="empty ? $t('SEARCH.SEARCHING_DATA') : $t('SEARCH.LOADING_DATA')"
/>
<div
v-if="empty && !isFetching"
class="flex items-start justify-center px-4 py-6 rounded-xl bg-n-slate-2 dark:bg-n-solid-1"
>
<Icon
icon="i-lucide-info"
class="text-n-slate-11 size-4 flex-shrink-0 mt-[3px]"
/>
<p class="mx-2 my-0 text-center text-n-slate-11">
{{ $t('SEARCH.EMPTY_STATE', { item: titleCase, query }) }}
</p>
</div>
</section>
</template>

View File

@@ -0,0 +1,64 @@
<script setup>
import { computed, watch, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
tabs: {
type: Array,
default: () => [],
},
selectedTab: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['tabChange']);
const { t } = useI18n();
const activeTab = ref(props.selectedTab);
watch(
() => props.selectedTab,
(value, oldValue) => {
if (value !== oldValue) {
activeTab.value = props.selectedTab;
}
}
);
const tabBarTabs = computed(() => {
return props.tabs.map(tab => ({
label: tab.name,
count: tab.showBadge ? tab.count : null,
}));
});
const onTabChange = selectedTab => {
const index = props.tabs.findIndex(tab => tab.name === selectedTab.label);
activeTab.value = index;
emit('tabChange', props.tabs[index].key);
};
</script>
<template>
<div class="flex items-center justify-between mt-7 mb-4">
<TabBar
:tabs="tabBarTabs"
:initial-active-tab="activeTab"
@tab-changed="onTabChange"
/>
<Button
:label="t('SEARCH.SORT_BY.RELEVANCE')"
sm
link
slate
class="hover:!no-underline pointer-events-none lg:inline-flex hidden"
icon="i-lucide-arrow-up-down"
/>
</div>
</template>

View File

@@ -0,0 +1,522 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useRouter, useRoute } from 'vue-router';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { generateURLParams, parseURLParams } from '../helpers/searchHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import Policy from 'dashboard/components/policy.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SearchHeader from './SearchHeader.vue';
import SearchTabs from './SearchTabs.vue';
import SearchResultConversationsList from './SearchResultConversationsList.vue';
import SearchResultMessagesList from './SearchResultMessagesList.vue';
import SearchResultContactsList from './SearchResultContactsList.vue';
import SearchResultArticlesList from './SearchResultArticlesList.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const PER_PAGE = 15; // Results per page
const selectedTab = ref(route.params.tab || 'all');
const query = ref(route.query.q || '');
const pages = ref({
contacts: 1,
conversations: 1,
messages: 1,
articles: 1,
});
const contactRecords = useMapGetter('conversationSearch/getContactRecords');
const conversationRecords = useMapGetter(
'conversationSearch/getConversationRecords'
);
const messageRecords = useMapGetter('conversationSearch/getMessageRecords');
const articleRecords = useMapGetter('conversationSearch/getArticleRecords');
const uiFlags = useMapGetter('conversationSearch/getUIFlags');
const addTypeToRecords = (records, type) =>
records.value.map(item => ({ ...useCamelCase(item, { deep: true }), type }));
const mappedContacts = computed(() =>
addTypeToRecords(contactRecords, 'contact')
);
const mappedConversations = computed(() =>
addTypeToRecords(conversationRecords, 'conversation')
);
const mappedMessages = computed(() =>
addTypeToRecords(messageRecords, 'message')
);
const mappedArticles = computed(() =>
addTypeToRecords(articleRecords, 'article')
);
const isSelectedTabAll = computed(() => selectedTab.value === 'all');
const searchResultSectionClass = computed(() => ({
'mt-4': isSelectedTabAll.value,
'mt-0.5': !isSelectedTabAll.value,
}));
const sliceRecordsIfAllTab = items =>
isSelectedTabAll.value ? items.value.slice(0, 5) : items.value;
const contacts = computed(() => sliceRecordsIfAllTab(mappedContacts));
const conversations = computed(() => sliceRecordsIfAllTab(mappedConversations));
const messages = computed(() => sliceRecordsIfAllTab(mappedMessages));
const articles = computed(() => sliceRecordsIfAllTab(mappedArticles));
const filterByTab = tab =>
computed(() => selectedTab.value === tab || isSelectedTabAll.value);
const filterContacts = filterByTab('contacts');
const filterConversations = filterByTab('conversations');
const filterMessages = filterByTab('messages');
const filterArticles = filterByTab('articles');
const { shouldShow, isFeatureFlagEnabled } = usePolicy();
const TABS_CONFIG = {
all: {
permissions: [
CONTACT_PERMISSIONS,
...ROLES,
...CONVERSATION_PERMISSIONS,
PORTAL_PERMISSIONS,
],
count: () => null, // No count for all tab
},
contacts: {
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => mappedContacts.value.length,
},
conversations: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedConversations.value.length,
},
messages: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => mappedMessages.value.length,
},
articles: {
permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => mappedArticles.value.length,
},
};
const tabs = computed(() => {
return Object.entries(TABS_CONFIG)
.map(([key, config]) => ({
key,
name: t(`SEARCH.TABS.${key.toUpperCase()}`),
count: config.count(),
showBadge: key !== 'all',
permissions: config.permissions,
featureFlag: config.featureFlag,
}))
.filter(config => {
// why the double check, glad you asked.
// Some features are marked as premium features, that means
// the feature will be visible, but a Paywall will be shown instead
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
});
});
const totalSearchResultsCount = computed(() => {
const permissionCounts = [
{
permissions: [...ROLES, CONTACT_PERMISSIONS],
count: () => contacts.value.length,
},
{
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
count: () => conversations.value.length + messages.value.length,
},
{
permissions: [...ROLES, PORTAL_PERMISSIONS],
featureFlag: FEATURE_FLAGS.HELP_CENTER,
count: () => articles.value.length,
},
];
return permissionCounts
.filter(config => {
// why the double check, glad you asked.
// Some features are marked as premium features, that means
// the feature will be visible, but a Paywall will be shown instead
// this works for pages and routes, but fails for UI elements like search here
// so we explicitly check if the feature is enabled
return (
shouldShow(config.featureFlag, config.permissions, null) &&
isFeatureFlagEnabled(config.featureFlag)
);
})
.map(config => {
return config.count();
})
.reduce((sum, count) => sum + count, 0);
});
const activeTabIndex = computed(() => {
const index = tabs.value.findIndex(tab => tab.key === selectedTab.value);
return index >= 0 ? index : 0;
});
const isFetchingAny = computed(() => {
const { contact, message, conversation, article, isFetching } = uiFlags.value;
return (
isFetching ||
contact.isFetching ||
message.isFetching ||
conversation.isFetching ||
article.isFetching
);
});
const showEmptySearchResults = computed(
() =>
totalSearchResultsCount.value === 0 &&
uiFlags.value.isSearchCompleted &&
isSelectedTabAll.value &&
!isFetchingAny.value &&
query.value
);
const showResultsSection = computed(
() =>
(uiFlags.value.isSearchCompleted && totalSearchResultsCount.value !== 0) ||
isFetchingAny.value ||
(!isSelectedTabAll.value && query.value && !isFetchingAny.value)
);
const showLoadMore = computed(() => {
if (!query.value || isFetchingAny.value || selectedTab.value === 'all')
return false;
const records = {
contacts: mappedContacts.value,
conversations: mappedConversations.value,
messages: mappedMessages.value,
articles: mappedArticles.value,
}[selectedTab.value];
return (
records?.length > 0 &&
records.length === pages.value[selectedTab.value] * PER_PAGE
);
});
const showViewMore = computed(() => ({
// Hide view more button if the number of records is less than 5
contacts: mappedContacts.value?.length > 5 && isSelectedTabAll.value,
conversations:
mappedConversations.value?.length > 5 && isSelectedTabAll.value,
messages: mappedMessages.value?.length > 5 && isSelectedTabAll.value,
articles: mappedArticles.value?.length > 5 && isSelectedTabAll.value,
}));
const filters = ref({
from: null,
in: null,
dateRange: { type: null, from: null, to: null },
});
const clearSearchResult = () => {
pages.value = { contacts: 1, conversations: 1, messages: 1, articles: 1 };
store.dispatch('conversationSearch/clearSearchResults');
};
const buildSearchPayload = (basePayload = {}, searchType = 'message') => {
const payload = { ...basePayload };
// Only include filters if advanced search is enabled
if (isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)) {
// Date filters apply to all search types
if (filters.value.dateRange.from) {
payload.since = filters.value.dateRange.from;
}
if (filters.value.dateRange.to) {
payload.until = filters.value.dateRange.to;
}
// Only messages support 'from' and 'inboxId' filters
if (searchType === 'message') {
if (filters.value.from) payload.from = filters.value.from;
if (filters.value.in) payload.inboxId = filters.value.in;
}
}
return payload;
};
const updateURL = () => {
const params = {
accountId: route.params.accountId,
...(selectedTab.value !== 'all' && { tab: selectedTab.value }),
};
const queryParams = {
...(query.value?.trim() && { q: query.value.trim() }),
...generateURLParams(
filters.value,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
),
};
router.replace({ name: 'search', params, query: queryParams });
};
const onSearch = q => {
query.value = q;
clearSearchResult();
updateURL();
if (!q) return;
useTrack(CONVERSATION_EVENTS.SEARCH_CONVERSATION);
const searchPayload = buildSearchPayload({ q, page: 1 });
store.dispatch('conversationSearch/fullSearch', searchPayload);
};
const onFilterChange = () => {
onSearch(query.value);
};
const onBack = () => {
if (window.history.length > 2) {
router.go(-1);
} else {
router.push({ name: 'home' });
}
clearSearchResult();
};
const loadMore = () => {
const SEARCH_ACTIONS = {
contacts: 'conversationSearch/contactSearch',
conversations: 'conversationSearch/conversationSearch',
messages: 'conversationSearch/messageSearch',
articles: 'conversationSearch/articleSearch',
};
if (uiFlags.value.isFetching || selectedTab.value === 'all') return;
const tab = selectedTab.value;
pages.value[tab] += 1;
const payload = buildSearchPayload(
{ q: query.value, page: pages.value[tab] },
tab
);
store.dispatch(SEARCH_ACTIONS[tab], payload);
};
const onTabChange = tab => {
selectedTab.value = tab;
updateURL();
};
onMounted(() => {
store.dispatch('conversationSearch/clearSearchResults');
store.dispatch('agents/get');
const parsedFilters = parseURLParams(
route.query,
isFeatureFlagEnabled(FEATURE_FLAGS.ADVANCED_SEARCH)
);
filters.value = parsedFilters;
// Auto-execute search if query parameter exists
if (route.query.q) {
onSearch(route.query.q);
}
});
onUnmounted(() => {
query.value = '';
store.dispatch('conversationSearch/clearSearchResults');
});
</script>
<template>
<div class="flex flex-col w-full h-full bg-n-surface-1">
<div class="flex w-full p-4">
<NextButton
:label="t('GENERAL_SETTINGS.BACK')"
icon="i-lucide-chevron-left"
faded
primary
sm
@click="onBack"
/>
</div>
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
<div class="w-full max-w-5xl mx-auto z-30">
<div class="flex flex-col w-full px-4">
<SearchHeader
v-model:filters="filters"
:initial-query="query"
@search="onSearch"
@filter-change="onFilterChange"
/>
<SearchTabs
v-if="query"
:tabs="tabs"
:selected-tab="activeTabIndex"
@tab-change="onTabChange"
/>
</div>
</div>
<div class="flex-grow w-full h-full overflow-y-auto">
<div class="w-full max-w-5xl mx-auto px-4 pb-6">
<div v-if="showResultsSection">
<Policy
:permissions="[...ROLES, CONTACT_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultContactsList
v-if="filterContacts"
:is-fetching="uiFlags.contact.isFetching"
:contacts="contacts"
:query="query"
:show-title="isSelectedTabAll"
class="mt-0.5"
/>
<NextButton
v-if="showViewMore.contacts"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'contacts'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultMessagesList
v-if="filterMessages"
:is-fetching="uiFlags.message.isFetching"
:messages="messages"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.messages"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'messages'"
/>
</Policy>
<Policy
:permissions="[...ROLES, ...CONVERSATION_PERMISSIONS]"
class="flex flex-col justify-center"
>
<SearchResultConversationsList
v-if="filterConversations"
:is-fetching="uiFlags.conversation.isFetching"
:conversations="conversations"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.conversations"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'conversations'"
/>
</Policy>
<Policy
v-if="isFeatureFlagEnabled(FEATURE_FLAGS.HELP_CENTER)"
:permissions="[...ROLES, PORTAL_PERMISSIONS]"
:feature-flag="FEATURE_FLAGS.HELP_CENTER"
class="flex flex-col justify-center"
>
<SearchResultArticlesList
v-if="filterArticles"
:is-fetching="uiFlags.article.isFetching"
:articles="articles"
:query="query"
:show-title="isSelectedTabAll"
:class="searchResultSectionClass"
/>
<NextButton
v-if="showViewMore.articles"
:label="t(`SEARCH.VIEW_MORE`)"
icon="i-lucide-eye"
slate
sm
outline
@click="selectedTab = 'articles'"
/>
</Policy>
<div v-if="showLoadMore" class="flex justify-center mt-3 mb-6">
<NextButton
v-if="!isSelectedTabAll"
:label="t(`SEARCH.LOAD_MORE`)"
icon="i-lucide-cloud-download"
slate
sm
faded
@click="loadMore"
/>
</div>
</div>
<div
v-else-if="showEmptySearchResults"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 rounded-md"
>
<fluent-icon icon="info" size="16px" class="text-n-slate-11" />
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_FULL', { query }) }}
</p>
</div>
<div
v-else-if="!query"
class="flex flex-col items-center justify-center px-4 py-6 mt-8 text-center rounded-md"
>
<p class="text-center margin-bottom-0">
<fluent-icon icon="search" size="24px" class="text-n-slate-11" />
</p>
<p class="m-2 text-center text-n-slate-11">
{{ t('SEARCH.EMPTY_STATE_DEFAULT') }}
</p>
</div>
</div>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup>
import { useExpandableContent } from 'shared/composables/useExpandableContent';
defineProps({
text: {
type: String,
default: '',
},
});
const { contentElement, showReadMore, showReadLess, toggleExpanded } =
useExpandableContent({ useResizeObserverForCheck: true });
</script>
<template>
<span class="py-2 text-xs font-medium">
{{ $t('SEARCH.TRANSCRIPT') }}
</span>
<div
class="text-n-slate-11 pt-1 text-sm rounded-lg w-full break-words grid items-center"
:class="showReadMore ? 'grid-cols-[1fr_auto]' : 'grid-cols-1'"
>
<div
ref="contentElement"
class="min-w-0"
:class="{ 'overflow-hidden line-clamp-1': showReadMore }"
>
{{ text }}
<button
v-if="showReadLess"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium ltr:ml-0.5 rtl:mr-0.5"
@click.prevent.stop="toggleExpanded(false)"
>
{{ $t('SEARCH.READ_LESS') }}
</button>
</div>
<button
v-if="showReadMore"
class="text-sm text-n-slate-11 underline cursor-pointer bg-transparent border-0 p-0 hover:text-n-slate-12 font-medium justify-self-end ltr:ml-0.5 rtl:mr-0.5"
@click.prevent.stop="toggleExpanded(true)"
>
{{ $t('SEARCH.READ_MORE') }}
</button>
</div>
</template>