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,178 @@
<script>
import { defineAsyncComponent, ref, computed } from 'vue';
import NextSidebar from 'next/sidebar/Sidebar.vue';
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
import AddAccountModal from 'dashboard/components/app/AddAccountModal.vue';
import UpgradePage from 'dashboard/routes/dashboard/upgrade/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import { useWindowSize } from '@vueuse/core';
import wootConstants from 'dashboard/constants/globals';
const CommandBar = defineAsyncComponent(
() => import('./commands/commandbar.vue')
);
const FloatingCallWidget = defineAsyncComponent(
() => import('dashboard/components/widgets/FloatingCallWidget.vue')
);
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
import MobileSidebarLauncher from 'dashboard/components-next/sidebar/MobileSidebarLauncher.vue';
import { useCallsStore } from 'dashboard/stores/calls';
export default {
components: {
NextSidebar,
CommandBar,
WootKeyShortcutModal,
AddAccountModal,
UpgradePage,
CopilotLauncher,
CopilotContainer,
FloatingCallWidget,
MobileSidebarLauncher,
},
setup() {
const upgradePageRef = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const { width: windowWidth } = useWindowSize();
const callsStore = useCallsStore();
return {
uiSettings,
updateUISettings,
accountId,
upgradePageRef,
windowWidth,
hasActiveCall: computed(() => callsStore.hasActiveCall),
hasIncomingCall: computed(() => callsStore.hasIncomingCall),
};
},
data() {
return {
showAccountModal: false,
showCreateAccountModal: false,
showShortcutModal: false,
isMobileSidebarOpen: false,
};
},
computed: {
isSmallScreen() {
return this.windowWidth < wootConstants.SMALL_SCREEN_BREAKPOINT;
},
showUpgradePage() {
return this.upgradePageRef?.shouldShowUpgradePage;
},
bypassUpgradePage() {
return [
'billing_settings_index',
'settings_inbox_list',
'general_settings_index',
'agent_list',
].includes(this.$route.name);
},
previouslyUsedDisplayType() {
const {
previously_used_conversation_display_type: conversationDisplayType,
} = this.uiSettings;
return conversationDisplayType;
},
},
watch: {
isSmallScreen: {
handler() {
const { LAYOUT_TYPES } = wootConstants;
if (window.innerWidth <= wootConstants.SMALL_SCREEN_BREAKPOINT) {
this.updateUISettings({
conversation_display_type: LAYOUT_TYPES.EXPANDED,
});
} else {
this.updateUISettings({
conversation_display_type: this.previouslyUsedDisplayType,
});
}
},
immediate: true,
},
},
methods: {
toggleMobileSidebar() {
this.isMobileSidebarOpen = !this.isMobileSidebarOpen;
},
closeMobileSidebar() {
this.isMobileSidebarOpen = false;
},
openCreateAccountModal() {
this.showAccountModal = false;
this.showCreateAccountModal = true;
},
closeCreateAccountModal() {
this.showCreateAccountModal = false;
},
toggleAccountModal() {
this.showAccountModal = !this.showAccountModal;
},
toggleKeyShortcutModal() {
this.showShortcutModal = true;
},
closeKeyShortcutModal() {
this.showShortcutModal = false;
},
},
};
</script>
<template>
<div class="flex flex-grow overflow-hidden text-n-slate-12">
<NextSidebar
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle-account-modal="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
@show-create-account-modal="openCreateAccountModal"
@close-mobile-sidebar="closeMobileSidebar"
/>
<main
class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden bg-n-surface-1"
>
<UpgradePage
v-show="showUpgradePage"
ref="upgradePageRef"
:bypass-upgrade-page="bypassUpgradePage"
>
<MobileSidebarLauncher
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle="toggleMobileSidebar"
/>
</UpgradePage>
<template v-if="!showUpgradePage">
<router-view />
<CommandBar />
<CopilotLauncher />
<MobileSidebarLauncher
:is-mobile-sidebar-open="isMobileSidebarOpen"
@toggle="toggleMobileSidebar"
/>
<CopilotContainer />
<FloatingCallWidget v-if="hasActiveCall || hasIncomingCall" />
</template>
<AddAccountModal
:show="showCreateAccountModal"
@close-account-create-modal="closeCreateAccountModal"
/>
<WootKeyShortcutModal
v-model:show="showShortcutModal"
@close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal"
/>
</main>
</div>
</template>

View File

@@ -0,0 +1,68 @@
import { frontendURL } from 'dashboard/helper/URLHelper.js';
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
permissions: ['administrator'],
};
const campaignsRoutes = {
routes: [
{
path: frontendURL('accounts/:accountId/campaigns'),
component: CampaignsPageRouteView,
children: [
{
path: '',
redirect: to => {
return { name: 'campaigns_ongoing_index', params: to.params };
},
},
{
path: 'ongoing',
name: 'campaigns_ongoing_index',
meta,
redirect: to => {
return { name: 'campaigns_livechat_index', params: to.params };
},
},
{
path: 'one_off',
name: 'campaigns_one_off_index',
meta,
redirect: to => {
return { name: 'campaigns_sms_index', params: to.params };
},
},
{
path: 'live_chat',
name: 'campaigns_livechat_index',
meta,
component: LiveChatCampaignsPage,
},
{
path: 'sms',
name: 'campaigns_sms_index',
meta,
component: SMSCampaignsPage,
},
{
path: 'whatsapp',
name: 'campaigns_whatsapp_index',
meta: {
...meta,
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
},
component: WhatsAppCampaignsPage,
},
],
},
],
};
export default campaignsRoutes;

View File

@@ -0,0 +1,28 @@
<script setup>
import { onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
defineProps({
keepAlive: { type: Boolean, default: true },
});
const store = useStore();
onMounted(() => {
store.dispatch('campaigns/get');
store.dispatch('labels/get');
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import LiveChatCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue';
import EditLiveChatCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/EditLiveChatCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import LiveChatCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/LiveChatCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const editLiveChatCampaignDialogRef = ref(null);
const confirmDeleteCampaignDialogRef = ref(null);
const selectedCampaign = ref(null);
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
const liveChatCampaigns = computed(
() => getters['campaigns/getLiveChatCampaigns'].value
);
const hasNoLiveChatCampaigns = computed(
() => liveChatCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleEdit = campaign => {
selectedCampaign.value = campaign;
editLiveChatCampaignDialogRef.value.dialogRef.open();
};
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.LIVE_CHAT.HEADER_TITLE')"
:button-label="t('CAMPAIGN.LIVE_CHAT.NEW_CAMPAIGN')"
@click="toggleLiveChatCampaignDialog()"
@close="toggleLiveChatCampaignDialog(false)"
>
<template #action>
<LiveChatCampaignDialog
v-if="showLiveChatCampaignDialog"
@close="toggleLiveChatCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex justify-center items-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoLiveChatCampaigns"
:campaigns="liveChatCampaigns"
is-live-chat-type
@edit="handleEdit"
@delete="handleDelete"
/>
<LiveChatCampaignEmptyState
v-else
:title="t('CAMPAIGN.LIVE_CHAT.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.LIVE_CHAT.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<EditLiveChatCampaignDialog
ref="editLiveChatCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -0,0 +1,72 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import SMSCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import SMSCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/SMSCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showSMSCampaignDialog, toggleSMSCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
const hasNoSMSCampaigns = computed(
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.SMS.HEADER_TITLE')"
:button-label="t('CAMPAIGN.SMS.NEW_CAMPAIGN')"
@click="toggleSMSCampaignDialog()"
@close="toggleSMSCampaignDialog(false)"
>
<template #action>
<SMSCampaignDialog
v-if="showSMSCampaignDialog"
@close="toggleSMSCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoSMSCampaigns"
:campaigns="SMSCampaigns"
@delete="handleDelete"
/>
<SMSCampaignEmptyState
v-else
:title="t('CAMPAIGN.SMS.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.SMS.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -0,0 +1,74 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const WhatsAppCampaigns = computed(
() => getters['campaigns/getWhatsAppCampaigns'].value
);
const hasNoWhatsAppCampaigns = computed(
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
@click="toggleWhatsAppCampaignDialog()"
@close="toggleWhatsAppCampaignDialog(false)"
>
<template #action>
<WhatsAppCampaignDialog
v-if="showWhatsAppCampaignDialog"
@close="toggleWhatsAppCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoWhatsAppCampaigns"
:campaigns="WhatsAppCampaigns"
@delete="handleDelete"
/>
<WhatsAppCampaignEmptyState
v-else
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import { computed, ref, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
import AssistantPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/AssistantPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
const { isOnChatwootCloud } = useAccount();
const dialogType = ref('');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedAssistant = ref(null);
const createAssistantDialog = ref(null);
const router = useRouter();
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createAssistantDialog.value.dialogRef.open());
};
const handleCreateClose = () => {
dialogType.value = '';
selectedAssistant.value = null;
};
const handleAfterCreate = newAssistant => {
// Navigate directly to documents page with the new assistant ID
if (newAssistant?.id) {
router.push({
name: 'captain_assistants_responses_index',
params: {
accountId: router.currentRoute.value.params.accountId,
assistantId: newAssistant.id,
},
});
}
};
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.HEADER')"
:show-pagination-footer="false"
:is-fetching="isFetching"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
is-empty
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/assistant-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-popover-dark.svg"
learn-more-url="https://chwt.app/captain-assistant"
/>
</template>
<template #emptyState>
<AssistantPageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<CreateAssistantDialog
v-if="dialogType"
ref="createAssistantDialog"
:type="dialogType"
:selected-assistant="selectedAssistant"
@close="handleCreateClose"
@created="handleAfterCreate"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,302 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistantId = computed(() => Number(route.params.assistantId));
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const searchQuery = ref('');
const newInlineRule = ref('');
const newDialogRule = ref('');
const guardrailsContent = computed(() => assistant.value?.guardrails || []);
const backUrl = computed(() => ({
name: 'captain_assistants_settings_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
}));
const displayGuardrails = computed(() =>
guardrailsContent.value.map((c, idx) => ({ id: idx, content: c }))
);
const guardrailsExample = [
{
id: 1,
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
id: 2,
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
id: 3,
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
const filteredGuardrails = computed(() => {
const query = searchQuery.value.trim();
if (!query) return displayGuardrails.value;
return picoSearch(displayGuardrails.value, query, ['content']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_guardrails_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_guardrails_suggestions: false });
};
// Bulk selection & hover state
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleRuleSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const buildSelectedCountLabel = computed(() => {
const count = displayGuardrails.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.UNSELECT_ALL', { count })
: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const saveGuardrails = async list => {
await store.dispatch('captainAssistants/update', {
id: assistantId.value,
assistant: { guardrails: list },
});
};
const addGuardrail = async content => {
try {
const newGuardrails = [...guardrailsContent.value, content];
await saveGuardrails(newGuardrails);
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.SUCCESS'));
} catch (error) {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
}
};
const editGuardrail = async ({ id, content }) => {
try {
const updated = [...guardrailsContent.value];
updated[id] = content;
await saveGuardrails(updated);
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.ERROR'));
}
};
const deleteGuardrail = async id => {
try {
const updated = guardrailsContent.value.filter((_, idx) => idx !== id);
await saveGuardrails(updated);
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
}
};
const bulkDeleteGuardrails = async () => {
try {
if (bulkSelectedIds.value.size === 0) return;
const updated = guardrailsContent.value.filter(
(_, idx) => !bulkSelectedIds.value.has(idx)
);
await saveGuardrails(updated);
bulkSelectedIds.value.clear();
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
}
};
const addAllExample = () => {
updateUISettings({ show_guardrails_suggestions: false });
try {
const exampleContents = guardrailsExample.map(example => example.content);
const newGuardrails = [...guardrailsContent.value, ...exampleContents];
saveGuardrails(newGuardrails);
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
}
};
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
:is-fetching="isFetching"
:back-url="backUrl"
:show-know-more="false"
:show-pagination-footer="false"
:show-assistant-switcher="false"
>
<template #body>
<SettingsHeader
:heading="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
:description="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedRules
:title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.TITLE')"
:items="guardrailsExample"
@add="addAllExample"
@close="closeSuggestedRules"
>
<template #default="{ item }">
<div class="flex items-center justify-between w-full">
<span class="text-sm text-n-slate-12">
{{ item.content }}
</span>
<Button
:label="
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD_SINGLE')
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addGuardrail(item.content)"
/>
</div>
</template>
</SuggestedRules>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="displayGuardrails"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.BULK_DELETE_BUTTON')
"
@bulk-delete="bulkDeleteGuardrails"
>
<template #default-actions>
<AddNewRulesDialog
v-model="newDialogRule"
:placeholder="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.PLACEHOLDER')
"
:button-label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TITLE')"
:confirm-label="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CREATE')
"
:cancel-label="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CANCEL')
"
@add="addGuardrail"
/>
<!-- Will enable this feature in future -->
<!-- <div class="h-4 w-px bg-n-strong" />
<Button
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TEST_ALL')"
xs
ghost
slate
class="!text-sm"
/> -->
</template>
</BulkSelectBar>
<div
v-if="displayGuardrails.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.LIST.SEARCH_PLACEHOLDER')
"
/>
</div>
</div>
<div v-if="displayGuardrails.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredGuardrails.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guardrail in filteredGuardrails"
:id="guardrail.id"
:key="guardrail.id"
:content="guardrail.content"
:is-selected="bulkSelectedIds.has(guardrail.id)"
:selectable="
hoveredCard === guardrail.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@edit="editGuardrail"
@delete="deleteGuardrail"
@hover="isHovered => handleRuleHover(isHovered, guardrail.id)"
/>
</div>
<AddNewRulesInput
v-model="newInlineRule"
:placeholder="
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.PLACEHOLDER')
"
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.SAVE')"
@add="addGuardrail"
/>
</div>
</template>
</PageLayout>
</template>

View File

@@ -0,0 +1,326 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistantId = computed(() => Number(route.params.assistantId));
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const searchQuery = ref('');
const newInlineRule = ref('');
const newDialogRule = ref('');
const guidelinesContent = computed(
() => assistant.value?.response_guidelines || []
);
const backUrl = computed(() => ({
name: 'captain_assistants_settings_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
}));
const displayGuidelines = computed(() =>
guidelinesContent.value.map((c, idx) => ({ id: idx, content: c }))
);
const guidelinesExample = [
{
id: 1,
content:
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
},
{
id: 2,
content:
'Reject queries that include offensive, discriminatory, or threatening language.',
},
{
id: 3,
content:
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
},
];
const filteredGuidelines = computed(() => {
const query = searchQuery.value.trim();
if (!query) return displayGuidelines.value;
return picoSearch(displayGuidelines.value, query, ['content']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_response_guidelines_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_response_guidelines_suggestions: false });
};
// Bulk selection & hover state
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleRuleSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const buildSelectedCountLabel = computed(() => {
const count = displayGuidelines.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.UNSELECT_ALL', {
count,
})
: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECT_ALL', {
count,
});
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const saveGuidelines = async list => {
await store.dispatch('captainAssistants/update', {
id: assistantId.value,
assistant: { response_guidelines: list },
});
};
const addGuideline = async content => {
try {
const updated = [...guidelinesContent.value, content];
await saveGuidelines(updated);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
}
};
const editGuideline = async ({ id, content }) => {
try {
const updated = [...guidelinesContent.value];
updated[id] = content;
await saveGuidelines(updated);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.ERROR'));
}
};
const deleteGuideline = async id => {
try {
const updated = guidelinesContent.value.filter((_, idx) => idx !== id);
await saveGuidelines(updated);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
}
};
const bulkDeleteGuidelines = async () => {
try {
if (bulkSelectedIds.value.size === 0) return;
const updated = guidelinesContent.value.filter(
(_, idx) => !bulkSelectedIds.value.has(idx)
);
await saveGuidelines(updated);
bulkSelectedIds.value.clear();
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
}
};
const addAllExample = async () => {
updateUISettings({ show_response_guidelines_suggestions: false });
try {
const exampleContents = guidelinesExample.map(example => example.content);
const newGuidelines = [...guidelinesContent.value, ...exampleContents];
await saveGuidelines(newGuidelines);
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
} catch {
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
}
};
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:is-fetching="isFetching"
:back-url="backUrl"
:show-know-more="false"
:show-pagination-footer="false"
:show-assistant-switcher="false"
>
<template #body>
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:description="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedRules
:title="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
:items="guidelinesExample"
@add="addAllExample"
@close="closeSuggestedRules"
>
<template #default="{ item }">
<div class="flex items-center justify-between w-full">
<span class="text-sm text-n-slate-12">
{{ item.content }}
</span>
<Button
:label="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.ADD_SINGLE'
)
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addGuideline(item.content)"
/>
</div>
</template>
</SuggestedRules>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="displayGuidelines"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.BULK_DELETE_BUTTON'
)
"
@bulk-delete="bulkDeleteGuidelines"
>
<template #default-actions>
<AddNewRulesDialog
v-model="newDialogRule"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.PLACEHOLDER'
)
"
:button-label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TITLE')
"
:confirm-label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CREATE')
"
:cancel-label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CANCEL')
"
@add="addGuideline"
/>
<!-- Will enable this feature in future -->
<!-- <div class="h-4 w-px bg-n-strong" />
<Button
:label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TEST_ALL')
"
sm
ghost
slate
/> -->
</template>
</BulkSelectBar>
<div
v-if="displayGuidelines.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.LIST.SEARCH_PLACEHOLDER'
)
"
/>
</div>
</div>
<div v-if="displayGuidelines.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredGuidelines.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.SEARCH_EMPTY_MESSAGE')
}}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<RuleCard
v-for="guideline in filteredGuidelines"
:id="guideline.id"
:key="guideline.id"
:content="guideline.content"
:is-selected="bulkSelectedIds.has(guideline.id)"
:selectable="
hoveredCard === guideline.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@hover="isHovered => handleRuleHover(isHovered, guideline.id)"
@edit="editGuideline"
@delete="deleteGuideline"
/>
</div>
<AddNewRulesInput
v-model="newInlineRule"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.PLACEHOLDER'
)
"
:label="
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.SAVE')
"
@add="addGuideline"
/>
</div>
</template>
</PageLayout>
</template>

View File

@@ -0,0 +1,110 @@
<script setup>
import { computed, watch, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import ConnectInboxDialog from 'dashboard/components-next/captain/pageComponents/inbox/ConnectInboxDialog.vue';
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/InboxPageEmptyState.vue';
const store = useStore();
const dialogType = ref('');
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
const assistantUiFlags = useMapGetter('captainAssistants/getUIFlags');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
const isFetchingAssistant = computed(() => assistantUiFlags.value.fetchingItem);
const isFetching = computed(() => uiFlags.value.fetchingList);
const captainInboxes = useMapGetter('captainInboxes/getRecords');
const selectedInbox = ref(null);
const disconnectInboxDialog = ref(null);
const handleDelete = () => {
disconnectInboxDialog.value.dialogRef.open();
};
const connectInboxDialog = ref(null);
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => connectInboxDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedInbox.value = captainInboxes.value.find(inbox => id === inbox.id);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
});
};
const handleCreateClose = () => {
dialogType.value = '';
selectedInbox.value = null;
};
watch(
assistantId,
newId => {
store.dispatch('captainInboxes/get', {
assistantId: newId,
});
},
{ immediate: true }
);
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.INBOXES.HEADER')"
:button-label="$t('CAPTAIN.INBOXES.ADD_NEW')"
:button-policy="['administrator']"
:is-fetching="isFetchingAssistant || isFetching"
:is-empty="!captainInboxes.length"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@click="handleCreate"
>
<template #emptyState>
<InboxPageEmptyState @click="handleCreate" />
</template>
<template #body>
<div class="flex flex-col gap-4">
<InboxCard
v-for="captainInbox in captainInboxes"
:id="captainInbox.id"
:key="captainInbox.id"
:inbox="captainInbox"
@action="handleAction"
/>
</div>
</template>
<DeleteDialog
v-if="selectedInbox"
ref="disconnectInboxDialog"
:entity="selectedInbox"
:delete-payload="{
assistantId: assistantId,
inboxId: selectedInbox.id,
}"
type="Inboxes"
/>
<ConnectInboxDialog
v-if="dialogType"
ref="connectInboxDialog"
:assistant-id="assistantId"
:type="dialogType"
@close="handleCreateClose"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
const route = useRoute();
const assistantId = computed(() => Number(route.params.assistantId));
</script>
<template>
<PageLayout
show-assistant-switcher
:show-pagination-footer="false"
:show-know-more="false"
class="h-full"
>
<template #body>
<div class="flex flex-col h-full">
<AssistantPlayground :assistant-id="assistantId" class="bg-n-solid-1" />
</div>
</template>
</PageLayout>
</template>

View File

@@ -0,0 +1,302 @@
<script setup>
import { computed, h, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { picoSearch } from '@scmmishra/pico-search';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
import SuggestedScenarios from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
import ScenariosCard from 'dashboard/components-next/captain/assistant/ScenariosCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import AddNewScenariosDialog from 'dashboard/components-next/captain/assistant/AddNewScenariosDialog.vue';
const { t } = useI18n();
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { formatMessage } = useMessageFormatter();
const assistantId = computed(() => Number(route.params.assistantId));
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingList);
const scenarios = useMapGetter('captainScenarios/getRecords');
const searchQuery = ref('');
const LINK_INSTRUCTION_CLASS =
'[&_a[href^="tool://"]]:text-n-iris-11 [&_a:not([href^="tool://"])]:text-n-slate-12 [&_a]:pointer-events-none [&_a]:cursor-default';
const renderInstruction = instruction => () =>
h('span', {
class: `text-sm text-n-slate-12 py-4 prose prose-sm min-w-0 break-words ${LINK_INSTRUCTION_CLASS}`,
innerHTML: instruction,
});
// Suggested example scenarios for quick add
const scenariosExample = [
{
id: 1,
title: 'Prospective Buyer',
description:
'Handle customers who are showing interest in purchasing a license',
instruction:
'If someone is interested in purchasing a license, ask them for following:\n\n1. How many licenses are they willing to purchase?\n2. Are they migrating from another platform?\n. Once these details are collected, do the following steps\n1. add a private note to with the information you collected using [Add Private Note](tool://add_private_note)\n2. Add label "sales" to the contact using [Add Label to Conversation](tool://add_label_to_conversation)\n3. Reply saying "one of us will reach out soon" and provide an estimated timeline for the response and [Handoff to Human](tool://handoff)',
tools: ['add_private_note', 'add_label_to_conversation', 'handoff'],
},
];
const filteredScenarios = computed(() => {
const query = searchQuery.value.trim();
const source = scenarios.value;
if (!query) return source;
return picoSearch(source, query, ['title', 'description', 'instruction']);
});
const shouldShowSuggestedRules = computed(() => {
return uiSettings.value?.show_scenarios_suggestions !== false;
});
const closeSuggestedRules = () => {
updateUISettings({ show_scenarios_suggestions: false });
};
// Bulk selection & hover state
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleRuleSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const buildSelectedCountLabel = computed(() => {
const count = scenarios.value.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.UNSELECT_ALL', { count })
: t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleRuleHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const getToolsFromInstruction = instruction => [
...new Set(
[...(instruction?.matchAll(/\(tool:\/\/([^)]+)\)/g) ?? [])].map(m => m[1])
),
];
const updateScenario = async scenario => {
try {
await store.dispatch('captainScenarios/update', {
id: scenario.id,
assistantId: assistantId.value,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.UPDATE.ERROR');
useAlert(errorMessage);
}
};
const deleteScenario = async id => {
try {
await store.dispatch('captainScenarios/delete', {
id,
assistantId: assistantId.value,
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.ERROR');
useAlert(errorMessage);
}
};
// TODO: Add bulk delete endpoint
const bulkDeleteScenarios = async ids => {
const idsArray = ids || Array.from(bulkSelectedIds.value);
await Promise.all(
idsArray.map(id =>
store.dispatch('captainScenarios/delete', {
id,
assistantId: assistantId.value,
})
)
);
bulkSelectedIds.value = new Set();
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
};
const addScenario = async scenario => {
try {
await store.dispatch('captainScenarios/create', {
assistantId: assistantId.value,
...scenario,
tools: getToolsFromInstruction(scenario.instruction),
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR');
useAlert(errorMessage);
}
};
const addAllExampleScenarios = async () => {
try {
scenariosExample.forEach(async scenario => {
await store.dispatch('captainScenarios/create', {
assistantId: assistantId.value,
...scenario,
});
});
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAPTAIN.ASSISTANTS.SCENARIOS.API.ADD.ERROR');
useAlert(errorMessage);
}
};
onMounted(() => {
store.dispatch('captainScenarios/get', {
assistantId: assistantId.value,
});
store.dispatch('captainTools/getTools');
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:is-fetching="isFetching"
:show-know-more="false"
:show-pagination-footer="false"
>
<template #body>
<SettingsHeader
:heading="$t('CAPTAIN.ASSISTANTS.SCENARIOS.TITLE')"
:description="$t('CAPTAIN.ASSISTANTS.SCENARIOS.DESCRIPTION')"
/>
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
<SuggestedScenarios
:title="$t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TITLE')"
:items="scenariosExample"
@close="closeSuggestedRules"
@add="addAllExampleScenarios"
>
<template #default="{ item }">
<div class="flex items-center gap-3 justify-between">
<span class="text-sm text-n-slate-12">
{{ item.title }}
</span>
<Button
:label="
$t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.ADD_SINGLE')
"
ghost
xs
slate
class="!text-sm !text-n-slate-11 flex-shrink-0"
@click="addScenario(item)"
/>
</div>
<div class="flex flex-col">
<span class="text-sm text-n-slate-11 mt-2">
{{ item.description }}
</span>
<component
:is="renderInstruction(formatMessage(item.instruction, false))"
/>
<span class="text-sm text-n-slate-11 font-medium mb-1">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.SUGGESTED.TOOLS_USED') }}
{{ item.tools?.map(tool => `@${tool}`).join(', ') }}
</span>
</div>
</template>
</SuggestedScenarios>
</div>
<div class="flex mt-7 flex-col gap-4">
<div class="flex justify-between items-center">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="scenarios"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="
$t('CAPTAIN.ASSISTANTS.SCENARIOS.BULK_ACTION.BULK_DELETE_BUTTON')
"
@bulk-delete="bulkDeleteScenarios"
>
<template #default-actions>
<AddNewScenariosDialog @add="addScenario" />
</template>
</BulkSelectBar>
<div
v-if="scenarios.length && bulkSelectedIds.size === 0"
class="max-w-[22.5rem] w-full min-w-0"
>
<Input
v-model="searchQuery"
:placeholder="
t('CAPTAIN.ASSISTANTS.SCENARIOS.LIST.SEARCH_PLACEHOLDER')
"
/>
</div>
</div>
<div v-if="scenarios.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.EMPTY_MESSAGE') }}
</span>
</div>
<div v-else-if="filteredScenarios.length === 0" class="mt-1 mb-2">
<span class="text-n-slate-11 text-sm">
{{ t('CAPTAIN.ASSISTANTS.SCENARIOS.SEARCH_EMPTY_MESSAGE') }}
</span>
</div>
<div v-else class="flex flex-col gap-2">
<ScenariosCard
v-for="scenario in filteredScenarios"
:id="scenario.id"
:key="scenario.id"
:title="scenario.title"
:description="scenario.description"
:instruction="scenario.instruction"
:tools="scenario.tools"
:is-selected="bulkSelectedIds.has(scenario.id)"
:selectable="
hoveredCard === scenario.id || bulkSelectedIds.size > 0
"
@select="handleRuleSelect"
@delete="deleteScenario(scenario.id)"
@update="updateScenario"
@hover="isHovered => handleRuleHover(isHovered, scenario.id)"
/>
</div>
</div>
</template>
</PageLayout>
</template>

View File

@@ -0,0 +1,196 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
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 DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const route = useRoute();
const router = useRouter();
const store = useStore();
const deleteAssistantDialog = ref(null);
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const assistants = useMapGetter('captainAssistants/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistantId = computed(() => Number(route.params.assistantId));
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](assistantId.value)
);
const controlItems = computed(() => {
return [
{
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',
},
{
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',
},
];
});
const handleSubmit = async updatedAssistant => {
try {
await store.dispatch('captainAssistants/update', {
id: assistantId.value,
...updatedAssistant,
});
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleDelete = () => {
deleteAssistantDialog.value.dialogRef.open();
};
const handleDeleteSuccess = () => {
// Get remaining assistants after deletion
const remainingAssistants = assistants.value.filter(
a => a.id !== assistantId.value
);
if (remainingAssistants.length > 0) {
// Navigate to the first available assistant's settings
const nextAssistant = remainingAssistants[0];
router.push({
name: 'captain_assistants_settings_index',
params: {
accountId: route.params.accountId,
assistantId: nextAssistant.id,
},
});
} else {
// No assistants left, redirect to create assistant page
router.push({
name: 'captain_assistants_create_index',
params: { accountId: route.params.accountId },
});
}
};
</script>
<template>
<PageLayout
: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">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.BASIC_SETTINGS.DESCRIPTION')
"
/>
<AssistantBasicSettingsForm
:assistant="assistant"
@submit="handleSubmit"
/>
</div>
<span class="h-px w-full bg-n-weak mt-2" />
<div class="flex flex-col gap-6">
<SettingsHeader
:heading="t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.SETTINGS.SYSTEM_SETTINGS.DESCRIPTION')
"
/>
<AssistantSystemSettingsForm
: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"
/>
</div>
</div>
</div>
</template>
<DeleteDialog
v-if="assistant"
ref="deleteAssistantDialog"
:entity="assistant"
type="Assistants"
translation-key="ASSISTANTS"
@delete-success="handleDeleteSuccess"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,133 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
const meta = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
name: 'captain_assistants_responses_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex,
name: 'captain_assistants_documents_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
component: AssistantScenariosIndex,
name: 'captain_assistants_scenarios_index',
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/playground'),
component: AssistantPlaygroundIndex,
name: 'captain_assistants_playground_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/inboxes'),
component: AssistantInboxesIndex,
name: 'captain_assistants_inboxes_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
component: AssistantSettingsIndex,
name: 'captain_assistants_settings_index',
meta,
},
// Settings sub-pages (guardrails and guidelines)
{
path: frontendURL(
'accounts/:accountId/captain/:assistantId/settings/guardrails'
),
component: AssistantGuardrailsIndex,
name: 'captain_assistants_guardrails_index',
meta: metaV2,
},
{
path: frontendURL(
'accounts/:accountId/captain/:assistantId/settings/guidelines'
),
component: AssistantGuidelinesIndex,
name: 'captain_assistants_guidelines_index',
meta: metaV2,
},
{
path: frontendURL('accounts/:accountId/captain/assistants'),
component: AssistantEmptyStateIndex,
name: 'captain_assistants_create_index',
meta: {
permissions: ['administrator', 'agent'],
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage,
name: 'captain_assistants_index',
meta,
},
];
export const routes = [
{
path: frontendURL('accounts/:accountId/captain'),
component: CaptainPageRouteView,
redirect: to => {
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_responses_index',
...to.params,
},
};
},
children: [...assistantRoutes],
},
];

View File

@@ -0,0 +1,166 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
const documents = useMapGetter('captainDocuments/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const documentsMeta = useMapGetter('captainDocuments/getMeta');
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null);
const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
const showRelatedResponses = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const relationQuestionDialog = ref(null);
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
const handleRelatedResponseClose = () => {
showRelatedResponses.value = false;
};
const handleCreateDialogClose = () => {
showCreateDialog.value = false;
};
const handleAction = ({ action, id }) => {
selectedDocument.value = documents.value.find(
captainDocument => id === captainDocument.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
}
});
};
const fetchDocuments = (page = 1) => {
const filterParams = { page };
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
store.dispatch('captainDocuments/get', filterParams);
};
const onPageChange = page => fetchDocuments(page);
const onDeleteSuccess = () => {
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
onPageChange(documentsMeta.value.page - 1);
}
};
onMounted(() => {
fetchDocuments();
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.DOCUMENTS.HEADER')"
:button-label="$t('CAPTAIN.DOCUMENTS.ADD_NEW')"
:button-policy="['administrator']"
:total-count="documentsMeta.totalCount"
:current-page="documentsMeta.page"
:show-pagination-footer="!isFetching && !!documents.length"
:is-fetching="isFetching"
:is-empty="!documents.length"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/document-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-popover-dark.svg"
learn-more-url="https://chwt.app/captain-document"
/>
</template>
<template #emptyState>
<DocumentPageEmptyState @click="handleCreateDocument" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<DocumentCard
v-for="doc in documents"
:id="doc.id"
:key="doc.id"
:name="doc.name || doc.external_link"
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
@action="handleAction"
/>
</div>
</template>
<RelatedResponses
v-if="showRelatedResponses"
ref="relationQuestionDialog"
:captain-document="selectedDocument"
@close="handleRelatedResponseClose"
/>
<CreateDocumentDialog
v-if="showCreateDialog"
ref="createDocumentDialog"
:assistant-id="selectedAssistantId"
@close="handleCreateDialogClose"
/>
<DeleteDialog
v-if="selectedDocument"
ref="deleteDocumentDialog"
:entity="selectedDocument"
type="Documents"
@delete-success="onDeleteSuccess"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,89 @@
<script setup>
import { computed, nextTick, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
const router = useRouter();
const { uiSettings } = useUISettings();
const route = useRoute();
const assistants = computed(
() => store.getters['captainAssistants/getRecords']
);
const isAssistantPresent = assistantId => {
return !!assistants.value.find(a => a.id === Number(assistantId));
};
const routeToView = (name, params) => {
router.replace({ name, params, replace: true });
};
const generateRouterParams = () => {
const { last_active_assistant_id: lastActiveAssistantId } =
uiSettings.value || {};
if (isAssistantPresent(lastActiveAssistantId)) {
return {
assistantId: lastActiveAssistantId,
};
}
if (assistants.value.length > 0) {
const { id: assistantId } = assistants.value[0];
return { assistantId };
}
return null;
};
const routeToLastActiveAssistant = () => {
const params = generateRouterParams();
// No assistants found, redirect to create page
if (!params) {
return routeToView('captain_assistants_create_index', {
accountId: route.params.accountId,
});
}
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
'captain_assistants_playground_index', // Playground page
'captain_assistants_inboxes_index', // Inboxes page
'captain_tools_index', // Tools page
'captain_assistants_settings_index', // Settings page
].includes(navigationPath);
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
...params,
});
};
const performRouting = async () => {
await store.dispatch('captainAssistants/get');
nextTick(() => routeToLastActiveAssistant());
};
onMounted(() => performRouting());
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
>
<Spinner />
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup>
import { watch } from 'vue';
import { useRoute } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
watch(
() => route.params.assistantId,
newAssistantId => {
if (
newAssistantId &&
newAssistantId !== String(uiSettings.value.last_active_assistant_id)
) {
updateUISettings({
last_active_assistant_id: Number(newAssistantId),
});
}
}
);
</script>
<template>
<div class="flex w-full h-full min-h-0">
<section class="flex flex-1 h-full px-0 overflow-hidden bg-n-surface-1">
<router-view />
</section>
</div>
</template>

View File

@@ -0,0 +1,331 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const pendingCount = useMapGetter('captainResponses/getPendingCount');
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleCreate = () => {
dialogType.value = 'create';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedResponse.value = responses.value.find(response => id === response.id);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
});
};
const handleNavigationAction = ({ id, type }) => {
if (type === 'Conversation') {
router.push({
name: 'inbox_conversation',
params: { conversation_id: id },
});
}
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
};
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'approved' };
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const buildSelectedCountLabel = computed(() => {
const count = responses.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = responses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
// Page is now empty after bulk action.
// Fetch the previous page if not already on the first page.
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchResponses(pageToFetch);
} else {
// Page still has responses left, re-fetch the same page.
fetchResponses(currentPage);
}
// Clear selection
bulkSelectedIds.value = new Set();
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
}
};
const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
const navigateToPendingFAQs = () => {
router.push({ name: 'captain_assistants_responses_pending' });
};
onMounted(() => {
initializeFromURL();
store.dispatch(
'captainResponses/fetchPendingCount',
selectedAssistantId.value
);
});
</script>
<template>
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="['administrator']"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
:is-empty="!responses.length"
:show-pagination-footer="!isFetching && !!responses.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
@update:current-page="onPageChange"
@click="handleCreate"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
/>
</template>
<template #search>
<div
v-if="bulkSelectedIds.size === 0"
class="flex gap-3 justify-between w-full items-center"
>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
</template>
<template #subHeader>
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="responses"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
}"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
/>
</template>
<template #emptyState>
<ResponsePageEmptyState @click="handleCreate" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<Banner
v-if="pendingCount > 0"
color="blue"
class="mb-4 -mt-3"
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
@action="navigateToPendingFAQs"
>
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
</Banner>
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"
:entity="selectedResponse"
type="Responses"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"
:type="dialogType"
:selected-response="selectedResponse"
@close="handleCreateClose"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,376 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const selectedResponse = ref(null);
const deleteDialog = ref(null);
const bulkDeleteDialog = ref(null);
const selectedAssistantId = computed(() => route.params.assistantId);
const dialogType = ref('');
const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
params: {
accountId: route.params.accountId,
assistantId: selectedAssistantId.value,
},
}));
// Filter out approved responses in pending view
const filteredResponses = computed(() =>
responses.value.filter(response => response.status !== 'approved')
);
const handleDelete = () => {
deleteDialog.value.dialogRef.open();
};
const handleAccept = async () => {
try {
await store.dispatch('captainResponses/update', {
id: selectedResponse.value.id,
status: 'approved',
});
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
} catch (error) {
const errorMessage =
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
useAlert(errorMessage);
} finally {
selectedResponse.value = null;
}
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createDialog.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
selectedResponse.value = filteredResponses.value.find(
response => id === response.id
);
nextTick(() => {
if (action === 'delete') {
handleDelete();
}
if (action === 'edit') {
handleEdit();
}
if (action === 'approve') {
handleAccept();
}
});
};
const handleNavigationAction = ({ id, type }) => {
if (type === 'Conversation') {
router.push({
name: 'inbox_conversation',
params: { conversation_id: id },
});
}
};
const handleCreateClose = () => {
dialogType.value = '';
selectedResponse.value = null;
};
const updateURLWithFilters = (page, search) => {
const query = {
page: page || 1,
};
if (search) {
query.search = search;
}
router.replace({ query });
};
const fetchResponses = (page = 1) => {
const filterParams = { page, status: 'pending' };
if (selectedAssistantId.value) {
filterParams.assistantId = selectedAssistantId.value;
}
if (searchQuery.value) {
filterParams.search = searchQuery.value;
}
// Update URL with current filters
updateURLWithFilters(page, searchQuery.value);
store.dispatch('captainResponses/get', filterParams);
};
// Bulk action
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const buildSelectedCountLabel = computed(() => {
const count = filteredResponses.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.RESPONSES.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchResponseAfterBulkAction = () => {
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
const currentPage = responseMeta.value?.page;
if (hasNoResponsesLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchResponses(pageToFetch);
} else {
fetchResponses(currentPage);
}
bulkSelectedIds.value = new Set();
};
const handleBulkApprove = async () => {
try {
await store.dispatch(
'captainBulkActions/handleBulkApprove',
Array.from(bulkSelectedIds.value)
);
fetchResponseAfterBulkAction();
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
);
}
};
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
fetchResponses(page);
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
onPageChange(responseMeta.value.page - 1);
}
};
const onBulkDeleteSuccess = () => {
fetchResponseAfterBulkAction();
};
const debouncedSearch = debounce(async () => {
fetchResponses(1);
}, 500);
const hasActiveFilters = computed(() => {
return Boolean(searchQuery.value);
});
const clearFilters = () => {
searchQuery.value = '';
fetchResponses(1);
};
const initializeFromURL = () => {
if (route.query.search) {
searchQuery.value = route.query.search;
}
const pageFromURL = parseInt(route.query.page, 10) || 1;
fetchResponses(pageFromURL);
};
onMounted(() => {
initializeFromURL();
});
</script>
<template>
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
:is-fetching="isFetching"
:is-empty="!filteredResponses.length"
:show-pagination-footer="!isFetching && !!filteredResponses.length"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:back-url="backUrl"
@update:current-page="onPageChange"
>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
:hide-actions="!isOnChatwootCloud"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
learn-more-url="https://chwt.app/captain-faq"
/>
</template>
<template #search>
<div
v-if="bulkSelectedIds.size === 0"
class="flex gap-3 justify-between w-full items-center"
>
<Input
v-model="searchQuery"
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
class="w-64"
size="sm"
type="search"
autofocus
@input="debouncedSearch"
/>
</div>
</template>
<template #subHeader>
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="filteredResponses"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{
'mb-2': bulkSelectedIds.size > 0,
}"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
>
<template #secondary-actions>
<Button
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
sm
ghost
icon="i-lucide-check"
class="!px-1.5"
@click="handleBulkApprove"
/>
</template>
</BulkSelectBar>
</template>
<template #emptyState>
<ResponsePageEmptyState
variant="pending"
:has-active-filters="hasActiveFilters"
@clear-filters="clearFilters"
/>
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<LimitBanner class="mb-5" />
<div class="flex flex-col gap-4">
<ResponseCard
v-for="response in filteredResponses"
:id="response.id"
:key="response.id"
:question="response.question"
:answer="response.answer"
:assistant="response.assistant"
:documentable="response.documentable"
:status="response.status"
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="false"
:show-actions="!bulkSelectedIds.has(response.id)"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, response.id)"
/>
</div>
</template>
<DeleteDialog
v-if="selectedResponse"
ref="deleteDialog"
:entity="selectedResponse"
type="Responses"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
@delete-success="onBulkDeleteSuccess"
/>
<CreateResponseDialog
v-if="dialogType"
ref="createDialog"
:type="dialogType"
:selected-response="selectedResponse"
@close="handleCreateClose"
/>
</PageLayout>
</template>

View File

@@ -0,0 +1,139 @@
<script setup>
import { computed, onMounted, ref, nextTick } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const store = useStore();
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
const customTools = useMapGetter('captainCustomTools/getRecords');
const isFetching = computed(() => uiFlags.value.fetchingList);
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
const createDialogRef = ref(null);
const deleteDialogRef = ref(null);
const selectedTool = ref(null);
const dialogType = ref('');
const fetchCustomTools = (page = 1) => {
store.dispatch('captainCustomTools/get', { page });
};
const onPageChange = page => fetchCustomTools(page);
const openCreateDialog = () => {
dialogType.value = 'create';
selectedTool.value = null;
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleEdit = tool => {
dialogType.value = 'edit';
selectedTool.value = tool;
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleDelete = tool => {
selectedTool.value = tool;
nextTick(() => deleteDialogRef.value.dialogRef.open());
};
const handleAction = ({ action, id }) => {
const tool = customTools.value.find(t => t.id === id);
if (action === 'edit') {
handleEdit(tool);
} else if (action === 'delete') {
handleDelete(tool);
}
};
const handleDialogClose = () => {
dialogType.value = '';
selectedTool.value = null;
};
const onDeleteSuccess = () => {
selectedTool.value = null;
// Check if page will be empty after deletion
if (customTools.value.length === 1 && customToolsMeta.value.page > 1) {
// Go to previous page if current page will be empty
onPageChange(customToolsMeta.value.page - 1);
} else {
// Refresh current page
fetchCustomTools(customToolsMeta.value.page);
}
};
onMounted(() => {
fetchCustomTools();
});
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
:button-policy="['administrator']"
:total-count="customToolsMeta.totalCount"
:current-page="customToolsMeta.page"
:show-pagination-footer="!isFetching && !!customTools.length"
:is-fetching="isFetching"
:is-empty="!customTools.length"
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
:show-know-more="false"
@update:current-page="onPageChange"
@click="openCreateDialog"
>
<template #paywall>
<CaptainPaywall />
</template>
<template #emptyState>
<CustomToolsPageEmptyState @click="openCreateDialog" />
</template>
<template #body>
<div class="flex flex-col gap-4">
<CustomToolCard
v-for="tool in customTools"
:id="tool.id"
:key="tool.id"
:title="tool.title"
:description="tool.description"
:endpoint-url="tool.endpoint_url"
:http-method="tool.http_method"
:auth-type="tool.auth_type"
:param-schema="tool.param_schema"
:enabled="tool.enabled"
:created-at="tool.created_at"
:updated-at="tool.updated_at"
@action="handleAction"
/>
</div>
</template>
</PageLayout>
<CreateCustomToolDialog
v-if="dialogType"
ref="createDialogRef"
:type="dialogType"
:selected-tool="selectedTool"
@close="handleDialogClose"
/>
<DeleteDialog
v-if="selectedTool"
ref="deleteDialogRef"
:entity="selectedTool"
type="CustomTools"
translation-key="CUSTOM_TOOLS"
@delete-success="onDeleteSuccess"
/>
</template>

View File

@@ -0,0 +1,72 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useEmitter } from 'dashboard/composables/emitter';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events';
import wootConstants from 'dashboard/constants/globals';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const showCustomSnoozeModal = ref(false);
const selectedChat = computed(() => getters.getSelectedChat.value);
const contextMenuChatId = computed(() => getters.getContextMenuChatId.value);
const toggleStatus = async (status, snoozedUntil) => {
await store.dispatch('toggleStatus', {
conversationId: selectedChat.value?.id || contextMenuChatId.value,
status,
snoozedUntil,
});
store.dispatch('setContextMenuChatId', null);
useAlert(t('CONVERSATION.CHANGE_STATUS'));
};
const onCmdSnoozeConversation = snoozeType => {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomSnoozeModal.value = true;
} else {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
};
const chooseSnoozeTime = customSnoozeTime => {
showCustomSnoozeModal.value = false;
if (customSnoozeTime) {
toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
};
const hideCustomSnoozeModal = () => {
// if we select custom snooze and the custom snooze modal is open
// Then if the custom snooze modal is closed then set the context menu chat id to null
store.dispatch('setContextMenuChatId', null);
showCustomSnoozeModal.value = false;
};
useEmitter(CMD_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
</script>
<template>
<woot-modal
v-model:show="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</template>

View File

@@ -0,0 +1,117 @@
<script setup>
import '@chatwoot/ninja-keys';
import { ref, computed, watchEffect, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import wootConstants from 'dashboard/constants/globals';
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
const store = useStore();
const { t } = useI18n();
const ninjakeys = ref(null);
// Added selectedSnoozeType to track the selected snooze type
// So if the selected snooze type is "custom snooze" then we set selectedSnoozeType with the CMD action id
// So that we can track the selected snooze type and when we close the command bar
const selectedSnoozeType = ref(null);
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const { inboxHotKeys } = useInboxHotKeys();
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const { conversationHotKeys } = useConversationHotKeys();
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
const hotKeys = computed(() => [
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
]);
const setCommandBarData = () => {
ninjakeys.value.data = hotKeys.value;
};
const onSelected = item => {
const {
detail: { action: { title = null, section = null, id = null } = {} } = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
selectedSnoozeType.value = null;
}
useTrack(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
setCommandBarData();
};
const onClosed = () => {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
store.dispatch('setContextMenuChatId', null);
}
};
watchEffect(() => {
if (ninjakeys.value) {
ninjakeys.value.data = hotKeys.value;
}
});
onMounted(setCommandBarData);
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
<template>
<ninja-keys
ref="ninjakeys"
noAutoLoadMdIcons
hideBreadcrumbs
:placeholder="placeholder"
@selected="onSelected"
@closed="onClosed"
/>
</template>
<style lang="scss">
ninja-keys {
--ninja-accent-color: rgba(39, 129, 246, 1);
--ninja-font-family: 'Inter';
z-index: 9999;
}
// Wrapped with body.dark to avoid overriding the default theme
// If OS is in dark theme and app is in light mode, It will prevent showing dark theme in command bar
body.dark {
ninja-keys {
--ninja-overflow-background: rgba(26, 29, 30, 0.5);
--ninja-modal-background: #151718;
--ninja-secondary-background-color: #26292b;
--ninja-selected-background: #26292b;
--ninja-footer-background: #2b2f31;
--ninja-text-color: #f8faf9;
--ninja-icon-color: #f8faf9;
--ninja-secondary-text-color: #c2c9c6;
}
}
</style>

View File

@@ -0,0 +1,171 @@
<script setup>
import { ref, computed, onMounted, reactive } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { debounce } from '@chatwoot/utils';
import { useCompaniesStore } from 'dashboard/stores/companies';
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
const DEFAULT_SORT_FIELD = 'name';
const DEBOUNCE_DELAY = 300;
const companiesStore = useCompaniesStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const companies = computed(() => companiesStore.getCompaniesList);
const meta = computed(() => companiesStore.getMeta);
const uiFlags = computed(() => companiesStore.getUIFlags);
const searchQuery = computed(() => route.query?.search || '');
const searchValue = ref(searchQuery.value);
const pageNumber = computed(() => Number(route.query?.page) || 1);
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { companies_sort_by: companySortBy = DEFAULT_SORT_FIELD } =
uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(companySortBy);
const sortState = reactive({
activeSort: initialSort,
activeOrdering: initialOrder,
});
const activeSort = computed(() => sortState.activeSort);
const activeOrdering = computed(() => sortState.activeOrdering);
const isFetchingList = computed(() => uiFlags.value.fetchingList);
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
const sortParam = computed(() => buildSortAttr());
const updateURLParams = (page, search = '', sort = '') => {
const query = {
...route.query,
page: page.toString(),
};
if (search) {
query.search = search;
} else {
delete query.search;
}
if (sort) {
query.sort = sort;
} else {
delete query.sort;
}
router.replace({ query });
};
const fetchCompanies = async (page, search, sort) => {
const currentPage = page ?? pageNumber.value;
const currentSearch = search ?? searchQuery.value;
const currentSort = sort ?? sortParam.value;
// Only update URL if arguments were explicitly provided
if (page !== undefined || search !== undefined || sort !== undefined) {
updateURLParams(currentPage, currentSearch, currentSort);
}
if (currentSearch) {
await companiesStore.search({
search: currentSearch,
page: currentPage,
sort: currentSort,
});
} else {
await companiesStore.get({
page: currentPage,
sort: currentSort,
});
}
};
const onSearch = debounce(query => {
searchValue.value = query;
fetchCompanies(1, query, sortParam.value);
}, DEBOUNCE_DELAY);
const onPageChange = page => {
fetchCompanies(page, searchValue.value, sortParam.value);
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
companies_sort_by: buildSortAttr(),
});
fetchCompanies(1, searchValue.value, buildSortAttr());
};
onMounted(() => {
searchValue.value = searchQuery.value;
fetchCompanies();
});
</script>
<template>
<CompaniesListLayout
:search-value="searchValue"
:header-title="t('COMPANIES.HEADER')"
:current-page="pageNumber"
:total-items="Number(meta.totalCount || 0)"
:active-sort="activeSort"
:active-ordering="activeOrdering"
:is-fetching-list="isFetchingList"
:show-pagination-footer="!!companies.length"
@update:current-page="onPageChange"
@update:sort="handleSort"
@search="onSearch"
>
<div v-if="isFetchingList" class="flex items-center justify-center p-8">
<span class="text-n-slate-11 text-base">{{
t('COMPANIES.LOADING')
}}</span>
</div>
<div
v-else-if="companies.length === 0"
class="flex items-center justify-center p-8"
>
<span class="text-n-slate-11 text-base">{{
t('COMPANIES.EMPTY_STATE.TITLE')
}}</span>
</div>
<div v-else class="flex flex-col gap-4">
<CompaniesCard
v-for="company in companies"
:id="company.id"
:key="company.id"
:name="company.name"
:domain="company.domain"
:contacts-count="company.contactsCount || 0"
:description="company.description"
:avatar-url="company.avatarUrl"
:updated-at="company.updatedAt"
/>
</div>
</CompaniesListLayout>
</template>

View File

@@ -0,0 +1,26 @@
import { frontendURL } from '../../../helper/URLHelper';
import CompaniesIndex from './pages/CompaniesIndex.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
const commonMeta = {
featureFlag: FEATURE_FLAGS.COMPANIES,
permissions: ['administrator', 'agent'],
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
export const routes = [
{
path: frontendURL('accounts/:accountId/companies'),
component: CompaniesIndex,
meta: commonMeta,
children: [
{
path: '',
name: 'companies_dashboard_index',
component: CompaniesIndex,
meta: commonMeta,
},
],
},
];

View File

@@ -0,0 +1,167 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
visibleContactIds: {
type: Array,
default: () => [],
},
selectedContactIds: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'clearSelection',
'assignLabels',
'toggleAll',
'deleteSelected',
]);
const { t } = useI18n();
const selectedCount = computed(() => props.selectedContactIds.length);
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
const showLabelSelector = ref(false);
const selectAllLabel = computed(() => {
if (!totalVisibleContacts.value) {
return '';
}
return t('CONTACTS_BULK_ACTIONS.SELECT_ALL', {
count: totalVisibleContacts.value,
});
});
const selectedCountLabel = computed(() =>
t('CONTACTS_BULK_ACTIONS.SELECTED_COUNT', {
count: selectedCount.value,
})
);
const allItems = computed(() =>
props.visibleContactIds.map(id => ({
id,
}))
);
const selectionModel = computed({
get: () => new Set(props.selectedContactIds),
set: newSet => {
if (!props.visibleContactIds.length) {
emit('toggleAll', false);
return;
}
const shouldSelectAll =
newSet.size === props.visibleContactIds.length && newSet.size > 0;
emit('toggleAll', shouldSelectAll);
},
});
const emitClearSelection = () => {
showLabelSelector.value = false;
emit('clearSelection');
};
const toggleLabelSelector = () => {
if (!selectedCount.value || props.isLoading) return;
showLabelSelector.value = !showLabelSelector.value;
};
const closeLabelSelector = () => {
showLabelSelector.value = false;
};
const handleAssignLabels = labels => {
emit('assignLabels', labels);
closeLabelSelector();
};
</script>
<template>
<div
class="sticky top-0 z-10 bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
>
<BulkSelectBar
v-model="selectionModel"
:all-items="allItems"
:select-all-label="selectAllLabel"
:selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
>
<template #secondary-actions>
<Button
sm
ghost
slate
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
class="!px-1.5"
@click="emitClearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<div
v-on-click-outside="closeLabelSelector"
class="relative flex items-center"
>
<Button
sm
faded
slate
icon="i-lucide-tags"
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="toggleLabelSelector"
/>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<LabelActions
v-if="showLabelSelector"
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
@assign="handleAssignLabels"
/>
</transition>
</div>
<Policy :permissions="['administrator']">
<Button
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
sm
faded
ruby
icon="i-lucide-trash"
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="emit('deleteSelected')"
/>
</Policy>
</div>
</template>
</BulkSelectBar>
</div>
</template>

View File

@@ -0,0 +1,147 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
OPERATOR_TYPES_5,
} from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
const filterTypes = [
{
attributeKey: 'name',
attributeI18nKey: 'NAME',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attribute_type: 'standard',
},
{
attributeKey: 'email',
attributeI18nKey: 'EMAIL',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'phone_number',
attributeI18nKey: 'PHONE_NUMBER',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'identifier',
attributeI18nKey: 'IDENTIFIER',
inputType: 'plain_text',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attribute_type: 'standard',
},
{
attributeKey: 'country_code',
attributeI18nKey: 'COUNTRY',
inputType: 'search_select',
dataType: 'number',
filterOperators: OPERATOR_TYPES_1,
attribute_type: 'standard',
},
{
attributeKey: 'city',
attributeI18nKey: 'CITY',
inputType: 'plain_text',
dataType: 'Number',
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
{
attributeKey: 'created_at',
attributeI18nKey: 'CREATED_AT',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'last_activity_at',
attributeI18nKey: 'LAST_ACTIVITY',
inputType: 'date',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'referer',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
dataType: 'text',
filterOperators: OPERATOR_TYPES_5,
attributeModel: 'standard',
},
{
attributeKey: 'blocked',
attributeI18nKey: 'BLOCKED',
inputType: 'search_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_1,
attributeModel: 'standard',
},
{
attributeKey: 'labels',
attributeI18nKey: 'LABELS',
inputType: 'multi_select',
dataType: 'text',
filterOperators: OPERATOR_TYPES_2,
attributeModel: 'standard',
},
];
export const filterAttributeGroups = [
{
name: 'Standard Filters',
i18nGroup: 'STANDARD_FILTERS',
attributes: [
{
key: 'name',
i18nKey: 'NAME',
},
{
key: 'email',
i18nKey: 'EMAIL',
},
{
key: 'phone_number',
i18nKey: 'PHONE_NUMBER',
},
{
key: 'identifier',
i18nKey: 'IDENTIFIER',
},
{
key: 'country_code',
i18nKey: 'COUNTRY',
},
{
key: 'city',
i18nKey: 'CITY',
},
{
key: 'created_at',
i18nKey: 'CREATED_AT',
},
{
key: 'last_activity_at',
i18nKey: 'LAST_ACTIVITY',
},
{
key: 'blocked',
i18nKey: 'BLOCKED',
},
{
key: 'labels',
i18nKey: 'LABELS',
},
],
},
];
export default filterTypes;

View File

@@ -0,0 +1,185 @@
<script setup>
import { onMounted, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute, useRouter } from 'vue-router';
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
const store = useStore();
const route = useRoute();
const router = useRouter();
const contact = useMapGetter('contacts/getContactById');
const uiFlags = useMapGetter('contacts/getUIFlags');
const activeTab = ref('attributes');
const contactMergeRef = ref(null);
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
const isMergingContact = computed(() => uiFlags.value.isMerging);
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
const selectedContact = computed(() => contact.value(route.params.contactId));
const showSpinner = computed(
() => isFetchingItem.value || isMergingContact.value
);
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'ATTRIBUTES', value: 'attributes' },
{ key: 'HISTORY', value: 'history' },
{ key: 'NOTES', value: 'notes' },
{ key: 'MERGE', value: 'merge' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
value: tab.value,
}));
});
const activeTabIndex = computed(() => {
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
});
const goToContactsList = () => {
if (window.history.state?.back || window.history.length > 1) {
router.back();
} else {
router.push(`/app/accounts/${route.params.accountId}/contacts?page=1`);
}
};
const fetchActiveContact = async () => {
if (route.params.contactId) {
await store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch(
'contacts/fetchContactableInbox',
route.params.contactId
);
}
};
const handleTabChange = tab => {
activeTab.value = tab.value;
};
const fetchContactNotes = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactNotes/get', { contactId });
};
const fetchContactConversations = () => {
const { contactId } = route.params;
if (contactId) store.dispatch('contactConversations/get', contactId);
};
const fetchAttributes = () => {
store.dispatch('attributes/get');
};
const toggleContactBlock = async isBlocked => {
const ALERT_MESSAGES = {
success: {
block: t('CONTACTS_LAYOUT.HEADER.ACTIONS.BLOCK_SUCCESS_MESSAGE'),
unblock: t('CONTACTS_LAYOUT.HEADER.ACTIONS.UNBLOCK_SUCCESS_MESSAGE'),
},
error: {
block: t('CONTACTS_LAYOUT.HEADER.ACTIONS.BLOCK_ERROR_MESSAGE'),
unblock: t('CONTACTS_LAYOUT.HEADER.ACTIONS.UNBLOCK_ERROR_MESSAGE'),
},
};
try {
await store.dispatch(`contacts/update`, {
...selectedContact.value,
blocked: !isBlocked,
});
useAlert(
isBlocked ? ALERT_MESSAGES.success.unblock : ALERT_MESSAGES.success.block
);
} catch (error) {
useAlert(
isBlocked ? ALERT_MESSAGES.error.unblock : ALERT_MESSAGES.error.block
);
}
};
onMounted(() => {
fetchActiveContact();
fetchContactNotes();
fetchContactConversations();
fetchAttributes();
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<ContactsDetailsLayout
:button-label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
:is-updating="isUpdatingContact"
@go-to-contacts-list="goToContactsList"
@toggle-block="toggleContactBlock"
>
<div
v-if="showSpinner"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<ContactDetails
v-else-if="selectedContact"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
/>
<template #sidebar>
<div class="px-6">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
class="w-full [&>button]:w-full bg-n-alpha-black2"
@tab-changed="handleTabChange"
/>
</div>
<div
v-if="isFetchingItem"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactCustomAttributes
v-if="activeTab === 'attributes'"
:selected-contact="selectedContact"
/>
<ContactNotes v-if="activeTab === 'notes'" />
<ContactHistory v-if="activeTab === 'history'" />
<ContactMerge
v-if="activeTab === 'merge'"
ref="contactMergeRef"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
/>
</template>
</template>
</ContactsDetailsLayout>
</div>
</template>

View File

@@ -0,0 +1,516 @@
<script setup>
import { onMounted, computed, ref, reactive, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { debounce } from '@chatwoot/utils';
import { useUISettings } from 'dashboard/composables/useUISettings';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkActionsAPI from 'dashboard/api/bulkActions';
const DEFAULT_SORT_FIELD = 'last_activity_at';
const DEBOUNCE_DELAY = 300;
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { updateUISettings, uiSettings } = useUISettings();
const contacts = useMapGetter('contacts/getContactsList');
const uiFlags = useMapGetter('contacts/getUIFlags');
const customViewsUiFlags = useMapGetter('customViews/getUIFlags');
const segments = useMapGetter('customViews/getContactCustomViews');
const appliedFilters = useMapGetter('contacts/getAppliedContactFilters');
const meta = useMapGetter('contacts/getMeta');
const searchQuery = computed(() => route.query?.search);
const searchValue = ref(searchQuery.value || '');
const pageNumber = computed(() => Number(route.query?.page) || 1);
// For infinite scroll in search, track page internally
const searchPageNumber = ref(1);
const isLoadingMore = ref(false);
const parseSortSettings = (sortString = '') => {
const hasDescending = sortString.startsWith('-');
const sortField = hasDescending ? sortString.slice(1) : sortString;
return {
sort: sortField || DEFAULT_SORT_FIELD,
order: hasDescending ? '-' : '',
};
};
const { contacts_sort_by: contactSortBy = '' } = uiSettings.value ?? {};
const { sort: initialSort, order: initialOrder } =
parseSortSettings(contactSortBy);
const sortState = reactive({
activeSort: initialSort,
activeOrdering: initialOrder,
});
const activeLabel = computed(() => route.params.label);
const activeSegmentId = computed(() => route.params.segmentId);
const isFetchingList = computed(
() => uiFlags.value.isFetching || customViewsUiFlags.value.isFetching
);
const currentPage = computed(() => Number(meta.value?.currentPage));
const totalItems = computed(() => meta.value?.count);
const hasMore = computed(() => meta.value?.hasMore ?? false);
const isSearchView = computed(() => !!searchQuery.value);
const selectedContactIds = ref([]);
const isBulkActionLoading = ref(false);
const bulkDeleteDialogRef = ref(null);
const selectedCount = computed(() => selectedContactIds.value.length);
const bulkDeleteDialogTitle = computed(() =>
selectedCount.value > 1
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE')
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE')
);
const bulkDeleteDialogDescription = computed(() =>
selectedCount.value > 1
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', {
count: selectedCount.value,
})
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION')
);
const bulkDeleteDialogConfirmLabel = computed(() =>
selectedCount.value > 1
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE')
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE')
);
const hasSelection = computed(() => selectedCount.value > 0);
const activeSegment = computed(() => {
if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value));
});
const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
);
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0;
});
const showEmptyStateLayout = computed(() => {
return (
!searchQuery.value &&
!hasContacts.value &&
isContactIndexView.value &&
!hasAppliedFilters.value
);
});
const showEmptyText = computed(() => {
return (
(searchQuery.value ||
hasAppliedFilters.value ||
!isContactIndexView.value) &&
!hasContacts.value
);
});
const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE');
});
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const visibleContactIds = computed(() =>
contacts.value.map(contact => contact.id)
);
const clearSelection = () => {
selectedContactIds.value = [];
};
const openBulkDeleteDialog = () => {
if (!selectedContactIds.value.length || isBulkActionLoading.value) return;
bulkDeleteDialogRef.value?.open?.();
};
const toggleSelectAll = shouldSelect => {
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
};
const toggleContactSelection = ({ id, value }) => {
const isAlreadySelected = selectedContactIds.value.includes(id);
const shouldSelect = value ?? !isAlreadySelected;
if (shouldSelect && !isAlreadySelected) {
selectedContactIds.value = [...selectedContactIds.value, id];
} else if (!shouldSelect && isAlreadySelected) {
selectedContactIds.value = selectedContactIds.value.filter(
contactId => contactId !== id
);
}
};
const updatePageParam = (page, search = '') => {
const query = {
...route.query,
page: page.toString(),
...(search ? { search } : {}),
};
if (!search) {
delete query.search;
}
router.replace({ query });
};
const buildSortAttr = () =>
`${sortState.activeOrdering}${sortState.activeSort}`;
const getCommonFetchParams = (page = 1) => ({
page,
sortAttr: buildSortAttr(),
label: activeLabel.value,
});
const fetchContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
};
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
clearSelection();
await store.dispatch('contacts/filter', {
...getCommonFetchParams(page),
queryPayload: payload,
});
updatePageParam(page);
};
const fetchActiveContacts = async (page = 1) => {
clearSelection();
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1, append = false) => {
if (!append) {
clearSelection();
searchPageNumber.value = 1;
}
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
if (!value) {
updatePageParam(page);
await fetchContacts(page);
return;
}
updatePageParam(page, value);
await store.dispatch('contacts/search', {
...getCommonFetchParams(page),
search: encodeURIComponent(value),
append,
});
searchPageNumber.value = page;
}, DEBOUNCE_DELAY);
const loadMoreSearchResults = async () => {
if (!hasMore.value || isLoadingMore.value) return;
isLoadingMore.value = true;
const nextPage = searchPageNumber.value + 1;
await store.dispatch('contacts/search', {
...getCommonFetchParams(nextPage),
search: encodeURIComponent(searchValue.value),
append: true,
});
searchPageNumber.value = nextPage;
isLoadingMore.value = false;
};
const fetchContactsBasedOnContext = async page => {
clearSelection();
updatePageParam(page, searchValue.value);
if (isFetchingList.value) return;
if (searchQuery.value) {
await searchContacts(searchQuery.value, page);
return;
}
// Reset the search value when we change the view
searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query
if (
(hasAppliedFilters.value || activeSegment.value?.query) &&
!activeLabel.value
) {
const queryPayload =
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
return;
}
// Default case: fetch regular contacts + label
await fetchContacts(page);
};
const assignLabels = async labels => {
if (!labels.length || !selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
labels: { add: labels },
});
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const deleteContacts = async () => {
if (!selectedContactIds.value.length) {
return;
}
isBulkActionLoading.value = true;
try {
await BulkActionsAPI.create({
type: 'Contact',
ids: selectedContactIds.value,
action_name: 'delete',
});
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS'));
clearSelection();
await fetchContactsBasedOnContext(pageNumber.value);
bulkDeleteDialogRef.value?.close?.();
} catch (error) {
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED'));
} finally {
isBulkActionLoading.value = false;
}
};
const handleSort = async ({ sort, order }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
await updateUISettings({
contacts_sort_by: buildSortAttr(),
});
if (searchQuery.value) {
await searchContacts(searchValue.value);
return;
}
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value
? activeSegment.value?.query
: filterQueryGenerator(appliedFilters.value)
)
: fetchContacts());
};
const createContact = async contact => {
await store.dispatch('contacts/create', contact);
};
watch(
contacts,
newContacts => {
const idsOnPage = newContacts.map(contact => contact.id);
selectedContactIds.value = selectedContactIds.value.filter(id =>
idsOnPage.includes(id)
);
},
{ deep: true }
);
watch(hasSelection, value => {
if (!value) {
bulkDeleteDialogRef.value?.close?.();
}
});
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
if (newSortBy) {
const { sort, order } = parseSortSettings(newSortBy);
sortState.activeSort = sort;
sortState.activeOrdering = order;
}
},
{ immediate: true }
);
watch(
[activeLabel, activeSegment, isActiveView],
() => {
fetchContactsBasedOnContext(pageNumber.value);
},
{ deep: true }
);
watch(searchQuery, value => {
if (isFetchingList.value) return;
searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group
if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts();
}
});
onMounted(async () => {
if (!activeSegmentId.value) {
if (searchQuery.value) {
await searchContacts(searchQuery.value, pageNumber.value);
return;
}
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact(
activeSegment.value.query,
pageNumber.value
);
}
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<ContactsListLayout
:search-value="searchValue"
:header-title="headerTitle"
:current-page="currentPage"
:total-items="totalItems"
:show-pagination-footer="!isFetchingList && hasContacts && !isSearchView"
:active-sort="sortState.activeSort"
:active-ordering="sortState.activeOrdering"
:active-segment="activeSegment"
:segments-id="activeSegmentId"
:is-fetching-list="isFetchingList"
:has-applied-filters="hasAppliedFilters"
:use-infinite-scroll="isSearchView"
:has-more="hasMore"
:is-loading-more="isLoadingMore"
@update:current-page="fetchContactsBasedOnContext"
@search="searchContacts"
@update:sort="handleSort"
@apply-filter="fetchSavedOrAppliedFilteredContact"
@clear-filters="fetchContacts"
@load-more="loadMoreSearchResults"
>
<div
v-if="isFetchingList && !(isSearchView && hasContacts)"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<template v-else>
<ContactsBulkActionBar
v-if="hasSelection"
:visible-contact-ids="visibleContactIds"
:selected-contact-ids="selectedContactIds"
:is-loading="isBulkActionLoading"
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
@delete-selected="openBulkDeleteDialog"
/>
<ContactEmptyState
v-if="showEmptyStateLayout"
class="pt-14"
:title="t('CONTACTS_LAYOUT.EMPTY_STATE.TITLE')"
:subtitle="t('CONTACTS_LAYOUT.EMPTY_STATE.SUBTITLE')"
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
@create="createContact"
/>
<div
v-else-if="showEmptyText"
class="flex items-center justify-center py-10"
>
<span class="text-base text-n-slate-11">
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@toggle-contact="toggleContactSelection"
/>
<Dialog
v-if="selectedCount"
ref="bulkDeleteDialogRef"
type="alert"
:title="bulkDeleteDialogTitle"
:description="bulkDeleteDialogDescription"
:confirm-button-label="bulkDeleteDialogConfirmLabel"
:is-loading="isBulkActionLoading"
@confirm="deleteContacts"
/>
</div>
</template>
</ContactsListLayout>
</div>
</template>

View File

@@ -0,0 +1,68 @@
import { frontendURL } from '../../../helper/URLHelper';
import ContactsIndex from './pages/ContactsIndex.vue';
import ContactManageView from './pages/ContactManageView.vue';
import { FEATURE_FLAGS } from '../../../featureFlags';
const commonMeta = {
featureFlag: FEATURE_FLAGS.CRM,
permissions: ['administrator', 'agent', 'contact_manage'],
};
export const routes = [
{
path: frontendURL('accounts/:accountId/contacts'),
component: ContactsIndex,
meta: commonMeta,
children: [
{
path: '',
name: 'contacts_dashboard_index',
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'segments/:segmentId',
name: 'contacts_dashboard_segments_index',
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'labels/:label',
name: 'contacts_dashboard_labels_index',
component: ContactsIndex,
meta: commonMeta,
},
{
path: 'active',
name: 'contacts_dashboard_active',
component: ContactsIndex,
meta: commonMeta,
},
],
},
{
path: frontendURL('accounts/:accountId/contacts/:contactId'),
component: ContactManageView,
meta: commonMeta,
children: [
{
path: '',
name: 'contacts_edit',
component: ContactManageView,
meta: commonMeta,
},
{
path: 'segments/:segmentId',
name: 'contacts_edit_segment',
component: ContactManageView,
meta: commonMeta,
},
{
path: 'labels/:label',
name: 'contacts_edit_label',
component: ContactManageView,
meta: commonMeta,
},
],
},
];

View File

@@ -0,0 +1,78 @@
<script>
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
import { mapGetters } from 'vuex';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
ConversationCard,
Spinner,
},
props: {
contactId: {
type: [String, Number],
required: true,
},
conversationId: {
type: [String, Number],
required: true,
},
},
computed: {
conversations() {
return this.$store.getters['contactConversations/getContactConversation'](
this.contactId
);
},
previousConversations() {
return this.conversations.filter(
conversation => conversation.id !== Number(this.conversationId)
);
},
...mapGetters({
uiFlags: 'contactConversations/getUIFlags',
}),
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.$store.dispatch('contactConversations/get', newContactId);
}
},
},
mounted() {
this.$store.dispatch('contactConversations/get', this.contactId);
},
};
</script>
<template>
<div v-if="!uiFlags.isFetching" class="">
<div v-if="!previousConversations.length" class="no-label-message px-4 p-3">
<span>
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>
</div>
<div v-else class="contact-conversation--list">
<ConversationCard
v-for="conversation in previousConversations"
:key="conversation.id"
:chat="conversation"
:hide-inbox-name="false"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
/>
</div>
</div>
<div v-else class="flex items-center justify-center py-5">
<Spinner />
</div>
</template>
<style lang="scss" scoped>
.no-label-message {
@apply text-n-slate-11 mb-4;
}
</style>

View File

@@ -0,0 +1,25 @@
<script>
export default {
props: {
title: { type: String, required: true },
value: { type: [String, Number], default: '' },
compact: { type: Boolean, default: false },
},
};
</script>
<template>
<div class="overflow-auto" :class="compact ? 'py-0 px-0' : 'py-3 px-4'">
<div class="items-center flex justify-between mb-1.5">
<span class="text-sm font-medium text-n-slate-12">
{{ title }}
</span>
<slot name="button" />
</div>
<div v-if="value" class="break-words">
<slot>
{{ value }}
</slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,312 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import {
useMapGetter,
useFunctionGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from './ContactConversations.vue';
import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import MacrosList from './Macros/List.vue';
import ShopifyOrdersList from 'dashboard/components/widgets/conversation/ShopifyOrdersList.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import LinearIssuesList from 'dashboard/components/widgets/conversation/linear/IssuesList.vue';
import LinearSetupCTA from 'dashboard/components/widgets/conversation/linear/LinearSetupCTA.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
inboxId: {
type: Number,
default: undefined,
},
});
const {
updateUISettings,
isContactSidebarItemOpen,
conversationSidebarItemsOrder,
toggleSidebarUIState,
} = useUISettings();
const dragging = ref(false);
const conversationSidebarItems = ref([]);
const shopifyIntegration = useFunctionGetter(
'integrations/getIntegration',
'shopify'
);
const isShopifyFeatureEnabled = computed(
() => shopifyIntegration.value.enabled
);
const { isCloudFeatureEnabled } = useAccount();
const isLinearFeatureEnabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.LINEAR)
);
const linearIntegration = useFunctionGetter(
'integrations/getIntegration',
'linear'
);
const isLinearClientIdConfigured = computed(() => {
return !!linearIntegration.value?.id;
});
const isLinearConnected = computed(
() => linearIntegration.value?.enabled || false
);
const store = useStore();
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => props.conversationId);
const conversationMetadataGetter = useMapGetter(
'conversationMetadata/getConversationMetadata'
);
const currentConversationMetaData = computed(() =>
conversationMetadataGetter.value(conversationId.value)
);
const conversationAdditionalAttributes = computed(
() => currentConversationMetaData.value.additional_attributes || {}
);
const channelType = computed(() => currentChat.value.meta?.channel);
const contactGetter = useMapGetter('contacts/getContact');
const contactId = computed(() => currentChat.value.meta?.sender?.id);
const contact = computed(() => contactGetter.value(contactId.value));
const contactAdditionalAttributes = computed(
() => contact.value.additional_attributes || {}
);
const getContactDetails = () => {
if (contactId.value) {
store.dispatch('contacts/show', { id: contactId.value });
}
};
watch(contactId, (newContactId, prevContactId) => {
if (newContactId && newContactId !== prevContactId) {
getContactDetails();
}
});
const onDragEnd = () => {
dragging.value = false;
updateUISettings({
conversation_sidebar_items_order: conversationSidebarItems.value,
});
};
const closeContactPanel = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: false,
});
};
onMounted(() => {
conversationSidebarItems.value = conversationSidebarItemsOrder.value;
getContactDetails();
store.dispatch('attributes/get', 0);
// Load integrations to ensure linear integration state is available
store.dispatch('integrations/get', 'linear');
});
</script>
<template>
<div class="w-full">
<SidebarActionsHeader
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
@close="closeContactPanel"
/>
<ContactInfo :contact="contact" :channel-type="channelType" />
<div class="px-2 pb-8 list-group">
<Draggable
:list="conversationSidebarItems"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="name"
class="flex flex-col gap-3"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div
v-if="element.name === 'conversation_actions'"
class="conversation--actions"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_ACTIONS')"
:is-open="isContactSidebarItemOpen('is_conv_actions_open')"
@toggle="
value => toggleSidebarUIState('is_conv_actions_open', value)
"
>
<ConversationAction
:conversation-id="conversationId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div
v-else-if="element.name === 'conversation_participants'"
class="conversation--actions"
>
<AccordionItem
:title="$t('CONVERSATION_PARTICIPANTS.SIDEBAR_TITLE')"
:is-open="isContactSidebarItemOpen('is_conv_participants_open')"
@toggle="
value =>
toggleSidebarUIState('is_conv_participants_open', value)
"
>
<ConversationParticipant
:conversation-id="conversationId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'conversation_info'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
compact
@toggle="
value => toggleSidebarUIState('is_conv_details_open', value)
"
>
<ConversationInfo
:conversation-attributes="conversationAdditionalAttributes"
:contact-attributes="contactAdditionalAttributes"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_attributes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_contact_attributes_open')"
compact
@toggle="
value =>
toggleSidebarUIState('is_contact_attributes_open', value)
"
>
<CustomAttributes
attribute-type="contact_attribute"
attribute-from="conversation_contact_panel"
:contact-id="contact.id"
:empty-state-message="
$t('CONVERSATION_CUSTOM_ATTRIBUTES.NO_RECORDS_FOUND')
"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'previous_conversation'">
<AccordionItem
v-if="contact.id"
:title="
$t('CONVERSATION_SIDEBAR.ACCORDION.PREVIOUS_CONVERSATION')
"
:is-open="isContactSidebarItemOpen('is_previous_conv_open')"
compact
@toggle="
value => toggleSidebarUIState('is_previous_conv_open', value)
"
>
<ContactConversations
:contact-id="contact.id"
:conversation-id="conversationId"
/>
</AccordionItem>
</div>
<woot-feature-toggle
v-else-if="element.name === 'macros'"
feature-key="macros"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.MACROS')"
:is-open="isContactSidebarItemOpen('is_macro_open')"
compact
@toggle="value => toggleSidebarUIState('is_macro_open', value)"
>
<MacrosList :conversation-id="conversationId" />
</AccordionItem>
</woot-feature-toggle>
<div
v-else-if="
element.name === 'linear_issues' &&
isLinearFeatureEnabled &&
isLinearClientIdConfigured
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.LINEAR_ISSUES')"
:is-open="isContactSidebarItemOpen('is_linear_issues_open')"
compact
@toggle="
value => toggleSidebarUIState('is_linear_issues_open', value)
"
>
<LinearSetupCTA v-if="!isLinearConnected" />
<LinearIssuesList v-else :conversation-id="conversationId" />
</AccordionItem>
</div>
<div
v-else-if="
element.name === 'shopify_orders' && isShopifyFeatureEnabled
"
>
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.SHOPIFY_ORDERS')"
:is-open="isContactSidebarItemOpen('is_shopify_orders_open')"
compact
@toggle="
value => toggleSidebarUIState('is_shopify_orders_open', value)
"
>
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_notes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
:is-open="isContactSidebarItemOpen('is_contact_notes_open')"
compact
@toggle="
value => toggleSidebarUIState('is_contact_notes_open', value)
"
>
<ContactNotes :contact-id="contactId" />
</AccordionItem>
</div>
</template>
</Draggable>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep {
.contact--profile {
@apply pb-3 border-b border-solid border-n-weak;
}
}
</style>

View File

@@ -0,0 +1,288 @@
<!-- eslint-disable vue/v-slot-style -->
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ContactDetailsItem from './ContactDetailsItem.vue';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import ConversationLabels from './labels/LabelBox.vue';
import { CONVERSATION_PRIORITY } from '../../../../shared/constants/messages';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
ContactDetailsItem,
MultiselectDropdown,
ConversationLabels,
NextButton,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { agentsList } = useAgentsList();
return {
agentsList,
};
},
data() {
return {
priorityOptions: [
{
id: null,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
thumbnail: `/assets/images/dashboard/priority/none.svg`,
},
{
id: CONVERSATION_PRIORITY.URGENT,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.URGENT}.svg`,
},
{
id: CONVERSATION_PRIORITY.HIGH,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.HIGH}.svg`,
},
{
id: CONVERSATION_PRIORITY.MEDIUM,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.MEDIUM}.svg`,
},
{
id: CONVERSATION_PRIORITY.LOW,
name: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
thumbnail: `/assets/images/dashboard/priority/${CONVERSATION_PRIORITY.LOW}.svg`,
},
],
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
teams: 'teams/getTeams',
}),
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{ id: 0, name: this.$t('TEAMS_SETTINGS.LIST.NONE') },
...this.teams,
];
}
return this.teams;
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
},
set(agent) {
const agentId = agent ? agent.id : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
});
},
},
assignedTeam: {
get() {
return this.currentChat.meta.team;
},
set(team) {
const conversationId = this.currentChat.id;
const teamId = team ? team.id : 0;
this.$store.dispatch('setCurrentChatTeam', { team, conversationId });
this.$store
.dispatch('assignTeam', { conversationId, teamId })
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_TEAM'));
});
},
},
assignedPriority: {
get() {
const selectedOption = this.priorityOptions.find(
opt => opt.id === this.currentChat.priority
);
return selectedOption || this.priorityOptions[0];
},
set(priorityItem) {
const conversationId = this.currentChat.id;
const oldValue = this.currentChat?.priority;
const priority = priorityItem ? priorityItem.id : null;
this.$store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
this.$store
.dispatch('assignPriority', { conversationId, priority })
.then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
oldValue,
newValue: priority,
from: 'Conversation Sidebar',
});
useAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority: priorityItem.name,
conversationId,
})
);
});
},
},
showSelfAssign() {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
return false;
},
},
methods: {
onSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
avatar_url,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail: avatar_url,
};
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
}
},
onClickAssignTeam(selectedItemTeam) {
if (this.assignedTeam && this.assignedTeam.id === selectedItemTeam.id) {
this.assignedTeam = null;
} else {
this.assignedTeam = selectedItemTeam;
}
},
onClickAssignPriority(selectedPriorityItem) {
const isSamePriority =
this.assignedPriority &&
this.assignedPriority.id === selectedPriorityItem.id;
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
},
},
};
</script>
<template>
<div>
<div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
>
<template #button>
<NextButton
v-if="showSelfAssign"
link
xs
icon="i-lucide-arrow-right"
class="!gap-1"
:label="$t('CONVERSATION_SIDEBAR.SELF_ASSIGN')"
@click="onSelfAssign"
/>
</template>
</ContactDetailsItem>
<MultiselectDropdown
:options="agentsList"
:selected-item="assignedAgent"
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.AGENT')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.AGENT')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.AGENT')
"
@select="onClickAssignAgent"
/>
</div>
<div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.TEAM_LABEL')"
/>
<MultiselectDropdown
:options="teamsList"
:selected-item="assignedTeam"
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
:no-search-result="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
"
:input-placeholder="
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
"
@select="onClickAssignTeam"
/>
</div>
<div>
<ContactDetailsItem compact :title="$t('CONVERSATION.PRIORITY.TITLE')" />
<MultiselectDropdown
:options="priorityOptions"
:selected-item="assignedPriority"
:multiselector-title="$t('CONVERSATION.PRIORITY.TITLE')"
:multiselector-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SELECT_PLACEHOLDER')
"
:no-search-result="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.NO_RESULTS')
"
:input-placeholder="
$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.INPUT_PLACEHOLDER')
"
@select="onClickAssignPriority"
/>
</div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_LABELS')"
/>
<ConversationLabels :conversation-id="conversationId" />
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup>
import { computed } from 'vue';
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
const props = defineProps({
conversationAttributes: {
type: Object,
default: () => ({}),
},
contactAttributes: {
type: Object,
default: () => ({}),
},
});
const referer = computed(() => props.conversationAttributes.referer);
const initiatedAt = computed(
() => props.conversationAttributes.initiated_at?.timestamp
);
const browserInfo = computed(() => props.conversationAttributes.browser);
const browserName = computed(() => {
if (!browserInfo.value) return '';
const { browser_name: name = '', browser_version: version = '' } =
browserInfo.value;
return `${name} ${version}`;
});
const browserLanguage = computed(() =>
getLanguageName(props.conversationAttributes.browser_language)
);
const platformName = computed(() => {
if (!browserInfo.value) return '';
const { platform_name: name = '', platform_version: version = '' } =
browserInfo.value;
return `${name} ${version}`;
});
const createdAtIp = computed(() => props.contactAttributes.created_at_ip);
const staticElements = computed(() =>
[
{
content: initiatedAt,
title: 'CONTACT_PANEL.INITIATED_AT',
key: 'static-initiated-at',
type: 'static_attribute',
},
{
content: browserLanguage,
title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
key: 'static-browser-language',
type: 'static_attribute',
},
{
content: referer,
title: 'CONTACT_PANEL.INITIATED_FROM',
key: 'static-referer',
type: 'static_attribute',
},
{
content: browserName,
title: 'CONTACT_PANEL.BROWSER',
key: 'static-browser',
type: 'static_attribute',
},
{
content: platformName,
title: 'CONTACT_PANEL.OS',
key: 'static-platform',
type: 'static_attribute',
},
{
content: createdAtIp,
title: 'CONTACT_PANEL.IP_ADDRESS',
key: 'static-ip-address',
type: 'static_attribute',
},
].filter(attribute => !!attribute.content.value)
);
</script>
<template>
<div class="conversation--details">
<CustomAttributes
:static-elements="staticElements"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
>
<template #staticItem="{ element }">
<ContactDetailsItem
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
>
<a
v-if="element.key === 'static-referer'"
:href="element.content.value"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-n-brand"
>
{{ element.content.value }}
</a>
</ContactDetailsItem>
</template>
</CustomAttributes>
</div>
</template>

View File

@@ -0,0 +1,229 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import ThumbnailGroup from 'dashboard/components/widgets/ThumbnailGroup.vue';
import MultiselectDropdownItems from 'shared/components/ui/MultiselectDropdownItems.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Spinner,
ThumbnailGroup,
MultiselectDropdownItems,
NextButton,
},
props: {
conversationId: {
type: [Number, String],
required: true,
},
},
setup() {
const { agentsList } = useAgentsList(false);
return {
agentsList,
};
},
data() {
return {
selectedWatchers: [],
showDropDown: false,
};
},
computed: {
...mapGetters({
watchersUiFlas: 'conversationWatchers/getUIFlags',
currentUser: 'getCurrentUser',
}),
watchersFromStore() {
return this.$store.getters['conversationWatchers/getByConversationId'](
this.conversationId
);
},
watchersList: {
get() {
return this.selectedWatchers;
},
set(participants) {
this.selectedWatchers = [...participants];
const userIds = participants.map(el => el.id);
this.updateParticipant(userIds);
},
},
isUserWatching() {
return this.selectedWatchers.some(
watcher => watcher.id === this.currentUser.id
);
},
thumbnailList() {
return this.selectedWatchers.slice(0, 4);
},
moreAgentCount() {
const maxThumbnailCount = 4;
return this.watchersList.length - maxThumbnailCount;
},
moreThumbnailsText() {
if (this.moreAgentCount > 1) {
return this.$t('CONVERSATION_PARTICIPANTS.REMANING_PARTICIPANTS_TEXT', {
count: this.moreAgentCount,
});
}
return this.$t('CONVERSATION_PARTICIPANTS.REMANING_PARTICIPANT_TEXT', {
count: 1,
});
},
showMoreThumbs() {
return this.moreAgentCount > 0;
},
totalWatchersText() {
if (this.selectedWatchers.length > 1) {
return this.$t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANTS_TEXT', {
count: this.selectedWatchers.length,
});
}
return this.$t('CONVERSATION_PARTICIPANTS.TOTAL_PARTICIPANT_TEXT', {
count: 1,
});
},
},
watch: {
conversationId() {
this.fetchParticipants();
},
watchersFromStore(participants = []) {
this.selectedWatchers = [...participants];
},
},
mounted() {
this.fetchParticipants();
this.$store.dispatch('agents/get');
},
methods: {
fetchParticipants() {
const conversationId = this.conversationId;
this.$store.dispatch('conversationWatchers/show', { conversationId });
},
async updateParticipant(userIds) {
const conversationId = this.conversationId;
let alertMessage = this.$t(
'CONVERSATION_PARTICIPANTS.API.SUCCESS_MESSAGE'
);
try {
await this.$store.dispatch('conversationWatchers/update', {
conversationId,
userIds,
});
} catch (error) {
alertMessage =
error?.message ||
this.$t('CONVERSATION_PARTICIPANTS.API.ERROR_MESSAGE');
} finally {
useAlert(alertMessage);
}
this.fetchParticipants();
},
onOpenDropdown() {
this.showDropDown = true;
},
onCloseDropdown() {
this.showDropDown = false;
},
onClickItem(agent) {
const isAgentSelected = this.watchersList.some(
participant => participant.id === agent.id
);
if (isAgentSelected) {
const updatedList = this.watchersList.filter(
participant => participant.id !== agent.id
);
this.watchersList = [...updatedList];
} else {
this.watchersList = [...this.watchersList, agent];
}
},
onSelfAssign() {
this.watchersList = [...this.selectedWatchers, this.currentUser];
},
},
};
</script>
<template>
<div class="relative">
<div class="flex justify-between">
<div class="flex justify-between w-full mb-1">
<div>
<p v-if="watchersList.length" class="m-0 text-sm total-watchers">
<Spinner v-if="watchersUiFlas.isFetching" size="tiny" />
{{ totalWatchersText }}
</p>
<p v-else class="m-0 text-sm text-n-slate-10">
{{ $t('CONVERSATION_PARTICIPANTS.NO_PARTICIPANTS_TEXT') }}
</p>
</div>
<NextButton
v-tooltip.left="$t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS')"
slate
ghost
sm
icon="i-lucide-settings"
class="relative -top-1"
:title="$t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS')"
@click="onOpenDropdown"
/>
</div>
</div>
<div class="flex items-center justify-between">
<ThumbnailGroup
:more-thumbnails-text="moreThumbnailsText"
:show-more-thumbnails-count="showMoreThumbs"
:users-list="thumbnailList"
/>
<p v-if="isUserWatching" class="m-0 text-sm text-n-slate-10">
{{ $t('CONVERSATION_PARTICIPANTS.YOU_ARE_WATCHING') }}
</p>
<NextButton
v-else
link
xs
icon="i-lucide-arrow-right"
class="!gap-1"
:label="$t('CONVERSATION_PARTICIPANTS.WATCH_CONVERSATION')"
@click="onSelfAssign"
/>
</div>
<div
v-on-clickaway="
() => {
onCloseDropdown();
}
"
:class="{
'block visible': showDropDown,
'hidden invisible': !showDropDown,
}"
class="border rounded-lg shadow-lg bg-n-alpha-3 absolute backdrop-blur-[100px] border-n-strong dark:border-n-strong p-2 z-[9999] box-border top-8 w-full"
>
<div class="flex items-center justify-between mb-1">
<h4
class="m-0 overflow-hidden text-sm whitespace-nowrap text-ellipsis text-n-slate-12"
>
{{ $t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS') }}
</h4>
<NextButton ghost slate xs icon="i-lucide-x" @click="onCloseDropdown" />
</div>
<MultiselectDropdownItems
:options="agentsList"
:selected-items="selectedWatchers"
has-thumbnail
@select="onClickItem"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,219 @@
<script>
import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default {
components: {
ChatList,
ConversationBox,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
},
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
return {
uiSettings,
updateUISettings,
accountId,
};
},
data() {
return {
showSearchModal: false,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
showMessageView() {
return this.conversationId ? true : !this.isOnExpandedLayout;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
},
},
watch: {
conversationId() {
this.fetchConversationIfUnavailable();
},
},
created() {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
},
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('portals/index');
this.initialize();
this.$watch('$store.state.route', () => this.initialize());
this.$watch('chatList.length', () => {
this.setActiveChat();
});
},
methods: {
onConversationLoad() {
this.fetchConversationIfUnavailable();
},
initialize() {
this.$store.dispatch('setActiveInbox', this.inboxId);
this.setActiveChat();
},
toggleConversationLayout() {
const { LAYOUT_TYPES } = wootConstants;
const {
conversation_display_type:
conversationDisplayType = LAYOUT_TYPES.CONDENSED,
} = this.uiSettings;
const newViewType =
conversationDisplayType === LAYOUT_TYPES.CONDENSED
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
this.updateUISettings({
conversation_display_type: newViewType,
previously_used_conversation_display_type: newViewType,
});
},
fetchConversationIfUnavailable() {
if (!this.conversationId) {
return;
}
const chat = this.findConversation();
if (!chat) {
this.$store.dispatch('getConversation', this.conversationId);
}
},
findConversation() {
const conversationId = parseInt(this.conversationId, 10);
const [chat] = this.chatList.filter(c => c.id === conversationId);
return chat;
},
setActiveChat() {
if (this.conversationId) {
const selectedConversation = this.findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === this.currentChat.id
) {
return;
}
const { messageId } = this.$route.query;
this.$store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
this.$store.dispatch('clearSelectedState');
}
},
onSearch() {
this.showSearchModal = true;
},
closeSearch() {
this.showSearchModal = false;
},
},
};
</script>
<template>
<section class="flex w-full h-full min-w-0">
<ChatList
:show-conversation-list="showConversationList"
:conversation-inbox="inboxId"
:label="label"
:team-id="teamId"
:conversation-type="conversationType"
:folders-id="foldersId"
:is-on-expanded-layout="isOnExpandedLayout"
@conversation-load="onConversationLoad"
/>
<ConversationBox
v-if="showMessageView"
:inbox-id="inboxId"
:is-on-expanded-layout="isOnExpandedLayout"
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CmdBarConversationSnooze />
</section>
</template>

View File

@@ -0,0 +1,117 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Draggable from 'vuedraggable';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import MacroItem from './MacroItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const store = useStore();
const { accountScopedUrl } = useAccount();
const { uiSettings, updateUISettings } = useUISettings();
const dragging = ref(false);
const macros = useMapGetter('macros/getMacros');
const uiFlags = useMapGetter('macros/getUIFlags');
const MACROS_ORDER_KEY = 'macros_display_order';
const orderedMacros = computed({
get: () => {
// Get saved order array and current macros
const savedOrder = uiSettings.value?.[MACROS_ORDER_KEY] ?? [];
const currentMacros = macros.value ?? [];
// Return unmodified macros if not present or macro is not available
if (!savedOrder.length || !currentMacros.length) {
return currentMacros;
}
// Create a Map of id -> position for faster lookups
const orderMap = new Map(savedOrder.map((id, index) => [id, index]));
return [...currentMacros].sort((a, b) => {
// Use Infinity for items not in saved order (pushes them to end)
const aPos = orderMap.get(a.id) ?? Infinity;
const bPos = orderMap.get(b.id) ?? Infinity;
return aPos - bPos;
});
},
set: newOrder => {
// Update settings with array of ids from new order
updateUISettings({
[MACROS_ORDER_KEY]: newOrder.map(({ id }) => id),
});
},
});
const onDragEnd = () => {
dragging.value = false;
};
onMounted(() => {
store.dispatch('macros/get');
});
</script>
<template>
<div>
<div v-if="!uiFlags.isFetching && !macros.length" class="p-3">
<p class="flex flex-col items-center justify-center h-full">
{{ $t('MACROS.LIST.404') }}
</p>
<router-link :to="accountScopedUrl('settings/macros')">
<NextButton
faded
xs
icon="i-lucide-plus"
class="mt-1"
:label="$t('MACROS.HEADER_BTN_TXT')"
/>
</router-link>
</div>
<div
v-if="uiFlags.isFetching"
class="flex items-center gap-2 justify-center p-6 text-n-slate-12"
>
<span class="text-sm">{{ $t('MACROS.LOADING') }}</span>
<Spinner class="size-5" />
</div>
<Draggable
v-if="!uiFlags.isFetching && macros.length"
v-model="orderedMacros"
class="p-1"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="id"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<MacroItem
:key="element.id"
:macro="element"
:conversation-id="conversationId"
/>
</template>
</Draggable>
</div>
</template>
<style scoped lang="scss">
.ghost {
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
}
</style>

View File

@@ -0,0 +1,91 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { CONVERSATION_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import MacroPreview from './MacroPreview.vue';
const props = defineProps({
macro: {
type: Object,
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
});
const store = useStore();
const { t } = useI18n();
const isExecuting = ref(false);
const showPreview = ref(false);
const executeMacro = async macro => {
try {
isExecuting.value = true;
await store.dispatch('macros/execute', {
macroId: macro.id,
conversationIds: [props.conversationId],
});
useTrack(CONVERSATION_EVENTS.EXECUTED_A_MACRO);
useAlert(t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
} catch (error) {
useAlert(t('MACROS.ERROR'));
} finally {
isExecuting.value = false;
}
};
const toggleMacroPreview = () => {
showPreview.value = !showPreview.value;
};
const closeMacroPreview = () => {
showPreview.value = false;
};
</script>
<template>
<div
class="relative flex items-center justify-between leading-4 rounded-md h-10 pl-3 pr-2"
:class="showPreview ? 'cursor-default' : 'drag-handle cursor-grab'"
>
<span
class="overflow-hidden whitespace-nowrap text-ellipsis font-medium text-n-slate-12"
>
{{ macro.name }}
</span>
<div class="flex items-center gap-1 justify-end">
<NextButton
v-tooltip.left-start="$t('MACROS.EXECUTE.PREVIEW')"
icon="i-lucide-info"
slate
faded
xs
@click="toggleMacroPreview"
/>
<NextButton
v-tooltip.left-start="$t('MACROS.EXECUTE.BUTTON_TOOLTIP')"
icon="i-lucide-play"
slate
faded
xs
:is-loading="isExecuting"
@click="executeMacro(macro)"
/>
</div>
<transition name="menu-slide">
<MacroPreview
v-if="showPreview"
v-on-clickaway="closeMacroPreview"
:macro="macro"
/>
</transition>
</div>
</template>

View File

@@ -0,0 +1,83 @@
<script setup>
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import {
resolveActionName,
resolveTeamIds,
resolveLabels,
resolveAgents,
} from 'dashboard/routes/dashboard/settings/macros/macroHelper';
const props = defineProps({
macro: {
type: Object,
required: true,
},
});
const labels = useMapGetter('labels/getLabels');
const teams = useMapGetter('teams/getTeams');
const agents = useMapGetter('agents/getAgents');
const getActionValue = (key, params) => {
const actionsMap = {
assign_team: resolveTeamIds(teams.value, params),
add_label: resolveLabels(labels.value, params),
remove_label: resolveLabels(labels.value, params),
assign_agent: resolveAgents(agents.value, params),
mute_conversation: null,
snooze_conversation: null,
resolve_conversation: null,
remove_assigned_team: null,
send_webhook_event: params[0],
send_message: params[0],
send_email_transcript: params[0],
add_private_note: params[0],
};
return actionsMap[key] || '';
};
const resolvedMacro = computed(() => {
return props.macro.actions.map(action => ({
actionName: resolveActionName(action.action_name),
actionValue: getActionValue(action.action_name, action.action_params),
}));
});
</script>
<template>
<div
class="macro-preview absolute border border-n-weak max-h-[22.5rem] z-50 w-64 rounded-md bg-n-alpha-3 backdrop-blur-[100px] shadow-lg bottom-8 right-8 overflow-y-auto p-4 text-left rtl:text-right"
>
<h6 class="mb-4 text-sm text-n-slate-12">
{{ macro.name }}
</h6>
<div
v-for="(action, i) in resolvedMacro"
:key="i"
class="relative pl-4 macro-block"
>
<div
v-if="i !== macro.actions.length - 1"
class="top-[0.390625rem] absolute -bottom-1 left-0 w-px bg-n-slate-6"
/>
<div
class="absolute -left-[0.21875rem] top-[0.2734375rem] w-2 h-2 rounded-full bg-n-solid-1 border-2 border-solid border-n-weak dark:border-n-slate-6"
/>
<p class="mb-1 text-xs text-n-slate-11">
{{ $t(`MACROS.ACTIONS.${action.actionName}`) }}
</p>
<p class="text-n-slate-12 text-sm">{{ action.actionValue }}</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.macro-preview {
.macro-block {
&:not(:last-child) {
@apply pb-2;
}
}
}
</style>

View File

@@ -0,0 +1,433 @@
<script>
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import countries from 'shared/constants/countries.js';
import { isPhoneNumberValid } from 'shared/helpers/Validators';
import parsePhoneNumber from 'libphonenumber-js';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
export default {
components: {
NextButton,
Avatar,
ComboBox,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
inProgress: {
type: Boolean,
default: false,
},
onSubmit: {
type: Function,
default: () => {},
},
},
emits: ['cancel', 'success'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
countries: countries,
companyName: '',
description: '',
email: '',
name: '',
phoneNumber: '',
activeDialCode: '',
avatarFile: null,
avatarUrl: '',
country: {
id: '',
name: '',
},
city: '',
socialProfileUserNames: {
facebook: '',
twitter: '',
linkedin: '',
github: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},
validations: {
name: {
required,
},
description: {},
email: {
email,
},
companyName: {},
phoneNumber: {},
bio: {},
},
computed: {
parsePhoneNumber() {
return parsePhoneNumber(this.phoneNumber);
},
isPhoneNumberNotValid() {
if (this.phoneNumber !== '') {
return (
!isPhoneNumberValid(this.phoneNumber, this.activeDialCode) ||
(this.phoneNumber !== '' ? this.activeDialCode === '' : false)
);
}
return false;
},
phoneNumberError() {
if (this.activeDialCode === '') {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DIAL_CODE_ERROR');
}
if (!isPhoneNumberValid(this.phoneNumber, this.activeDialCode)) {
return this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.ERROR');
}
return '';
},
setPhoneNumber() {
if (this.parsePhoneNumber && this.parsePhoneNumber.countryCallingCode) {
return this.phoneNumber;
}
if (this.phoneNumber === '' && this.activeDialCode !== '') {
return '';
}
return this.activeDialCode
? `${this.activeDialCode}${this.phoneNumber}`
: '';
},
},
watch: {
contact() {
this.setContactObject();
},
},
mounted() {
this.setContactObject();
this.setDialCode();
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('success');
},
countryNameWithCode({ name, id }) {
if (!id) return name;
if (!name && !id) return '';
return `${name} (${id})`;
},
onCountryChange(value) {
const selected = this.countries.find(c => c.id === value);
this.country = selected
? { id: selected.id, name: selected.name }
: { id: '', name: '' };
},
setDialCode() {
if (
this.phoneNumber !== '' &&
this.parsePhoneNumber &&
this.parsePhoneNumber.countryCallingCode
) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
this.activeDialCode = `+${dialCode}`;
}
},
setContactObject() {
const {
email: emailAddress,
phone_number: phoneNumber,
name,
} = this.contact;
const additionalAttributes = this.contact.additional_attributes || {};
this.name = name || '';
this.email = emailAddress || '';
this.phoneNumber = phoneNumber || '';
this.companyName = additionalAttributes.company_name || '';
this.country = {
id: additionalAttributes.country_code || '',
name:
additionalAttributes.country ||
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY'),
};
this.city = additionalAttributes.city || '';
this.description = additionalAttributes.description || '';
this.avatarUrl = this.contact.thumbnail || '';
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
},
getContactObject() {
if (this.country === null) {
this.country = {
id: '',
name: '',
};
}
const contactObject = {
id: this.contact.id,
name: this.name,
email: this.email,
phone_number: this.setPhoneNumber,
additional_attributes: {
...this.contact.additional_attributes,
description: this.description,
company_name: this.companyName,
country_code: this.country.id,
country:
this.country.name ===
this.$t('CONTACT_FORM.FORM.COUNTRY.SELECT_COUNTRY')
? ''
: this.country.name,
city: this.city,
social_profiles: this.socialProfileUserNames,
},
};
if (this.avatarFile) {
contactObject.avatar = this.avatarFile;
contactObject.isFormData = true;
}
return contactObject;
},
setPhoneCode(code) {
if (this.phoneNumber !== '' && this.parsePhoneNumber) {
const dialCode = this.parsePhoneNumber.countryCallingCode;
if (dialCode === code) {
return;
}
this.activeDialCode = `+${dialCode}`;
const newPhoneNumber = this.phoneNumber.replace(
`+${dialCode}`,
`${code}`
);
this.phoneNumber = newPhoneNumber;
} else {
this.activeDialCode = code;
}
},
async handleSubmit() {
this.v$.$touch();
if (this.v$.$invalid || this.isPhoneNumberNotValid) {
return;
}
try {
await this.onSubmit(this.getContactObject());
this.onSuccess();
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof DuplicateContactException) {
if (error.data.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (error.data.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
handleImageUpload({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async handleAvatarDelete() {
try {
if (this.contact && this.contact.id) {
await this.$store.dispatch('contacts/deleteAvatar', this.contact.id);
useAlert(this.$t('CONTACT_FORM.DELETE_AVATAR.API.SUCCESS_MESSAGE'));
}
this.avatarFile = null;
this.avatarUrl = '';
this.activeDialCode = '';
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('CONTACT_FORM.DELETE_AVATAR.API.ERROR_MESSAGE')
);
}
},
},
};
</script>
<template>
<form
class="w-full px-8 pt-6 pb-8 contact--form"
@submit.prevent="handleSubmit"
>
<div class="flex flex-col mb-4 items-start gap-1 w-full">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ $t('CONTACT_FORM.FORM.AVATAR.LABEL') }}
</label>
<Avatar
:src="avatarUrl"
:size="72"
:name="contact.name"
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<div>
<div class="w-full">
<label :class="{ error: v$.name.$error }">
{{ $t('CONTACT_FORM.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.NAME.PLACEHOLDER')"
@input="v$.name.$touch"
/>
</label>
<label :class="{ error: v$.email.$error }">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.LABEL') }}
<input
v-model="email"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.PLACEHOLDER')"
@input="v$.email.$touch"
/>
<span v-if="v$.email.$error" class="message">
{{ $t('CONTACT_FORM.FORM.EMAIL_ADDRESS.ERROR') }}
</span>
</label>
</div>
</div>
<div class="w-full">
<label :class="{ error: v$.description.$error }">
{{ $t('CONTACT_FORM.FORM.BIO.LABEL') }}
<textarea
v-model="description"
type="text"
:placeholder="$t('CONTACT_FORM.FORM.BIO.PLACEHOLDER')"
@input="v$.description.$touch"
/>
</label>
</div>
<div>
<div class="w-full">
<label
:class="{
error: isPhoneNumberNotValid,
}"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.LABEL') }}
<woot-phone-input
v-model="phoneNumber"
:value="phoneNumber"
:error="isPhoneNumberNotValid"
:placeholder="$t('CONTACT_FORM.FORM.PHONE_NUMBER.PLACEHOLDER')"
@blur="v$.phoneNumber.$touch"
@set-code="setPhoneCode"
/>
<span v-if="isPhoneNumberNotValid" class="message">
{{ phoneNumberError }}
</span>
</label>
<div
v-if="isPhoneNumberNotValid || !phoneNumber"
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-md text-sm border border-solid border-n-amber-5 text-n-amber-12 bg-n-amber-3"
>
{{ $t('CONTACT_FORM.FORM.PHONE_NUMBER.HELP') }}
</div>
</div>
</div>
<woot-input
v-model="companyName"
class="w-full"
:label="$t('CONTACT_FORM.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="w-full mb-4">
<label>
{{ $t('CONTACT_FORM.FORM.COUNTRY.LABEL') }}
</label>
<ComboBox
:model-value="country.id"
:options="
countries.map(c => ({
value: c.id,
label: countryNameWithCode(c),
}))
"
class="[&>div>button]:!bg-n-alpha-black2"
:placeholder="$t('CONTACT_FORM.FORM.COUNTRY.PLACEHOLDER')"
:search-placeholder="$t('CONTACT_FORM.FORM.COUNTRY.SELECT_PLACEHOLDER')"
@update:model-value="onCountryChange"
/>
</div>
<woot-input
v-model="city"
class="w-full"
:label="$t('CONTACT_FORM.FORM.CITY.LABEL')"
:placeholder="$t('CONTACT_FORM.FORM.CITY.PLACEHOLDER')"
/>
<div class="w-full">
<label>{{ $t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES') }}</label>
<div
v-for="socialProfile in socialProfileKeys"
:key="socialProfile.key"
class="flex items-stretch w-full mb-4"
>
<span
class="flex items-center h-10 px-2 text-sm border-solid border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md bg-n-solid-3 text-n-slate-11 border-n-weak"
>
{{ socialProfile.prefixURL }}
</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
type="text"
/>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<NextButton
type="submit"
:label="$t('CONTACT_FORM.FORM.SUBMIT')"
:is-loading="inProgress"
/>
<NextButton
faded
slate
type="reset"
:label="$t('CONTACT_FORM.FORM.CANCEL')"
@click.prevent="onCancel"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,337 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from '../../../../helper/routeHelpers';
import { emitter } from 'shared/helpers/mitt';
export default {
components: {
NextButton,
ContactInfoRow,
EditContact,
Avatar,
ComposeConversation,
SocialIcons,
ContactMergeModal,
VoiceCallButton,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
showAvatar: {
type: Boolean,
default: true,
},
},
emits: ['panelClose'],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showEditModal: false,
showDeleteModal: false,
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
contactProfileLink() {
return `/app/accounts/${this.$route.params.accountId}/contacts/${this.contact.id}`;
},
additionalAttributes() {
return this.contact.additional_attributes || {};
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) {
return '';
}
return this.findCountryFlag(countryCode, cityAndCountry);
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
return {
twitter: twitterScreenName,
telegram: telegramUsername,
...(socialProfiles || {}),
};
},
// Delete Modal
confirmDeleteMessage() {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
handler(id) {
this.$store.dispatch('contacts/fetchContactableInbox', id);
},
immediate: true,
},
},
methods: {
dynamicTime,
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
confirmDeletion() {
this.deleteContact(this.contact);
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
return `${cityAndCountry} 🌎`;
}
const code = countryCode?.toLowerCase();
return `${cityAndCountry} <span class="fi fi-${code} size-3.5"></span>`;
} catch (error) {
return '';
}
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
this.$emit('panelClose');
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
if (isAConversationRoute(this.$route.name)) {
this.$router.push({
name: getConversationDashboardRoute(this.$route.name),
});
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
},
openMergeModal() {
this.$refs.mergeModal?.open();
},
},
};
</script>
<template>
<div class="relative items-center w-full p-4">
<div class="flex flex-col w-full gap-2 text-left rtl:text-right">
<div class="flex flex-row justify-between">
<Avatar
v-if="showAvatar"
:src="contact.thumbnail"
:name="contact.name"
:status="contact.availability_status"
:size="48"
hide-offline-status
rounded-full
/>
</div>
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
<h3
class="flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12"
>
{{ contact.name }}
</h3>
<div class="flex flex-row items-center gap-2">
<span
v-if="contact.created_at"
v-tooltip.left="
`${$t('CONTACT_PANEL.CREATED_AT_LABEL')} ${dynamicTime(
contact.created_at
)}`
"
class="i-lucide-info text-sm text-n-slate-10"
/>
<a
:href="contactProfileLink"
target="_blank"
rel="noopener nofollow noreferrer"
class="leading-3"
>
<span class="i-lucide-external-link text-sm text-n-slate-10" />
</a>
</div>
</div>
<p v-if="additionalAttributes.description" class="break-words mb-0.5">
{{ additionalAttributes.description }}
</p>
<div class="flex flex-col items-start w-full gap-2">
<ContactInfoRow
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="mail"
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
icon="call"
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
/>
<ContactInfoRow
v-if="contact.identifier"
:value="contact.identifier"
icon="contact-identify"
emoji="🪪"
:title="$t('CONTACT_PANEL.IDENTIFIER')"
/>
<ContactInfoRow
:value="additionalAttributes.company_name"
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<SocialIcons :social-profiles="socialProfiles" />
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@close="closeComposeConversationModal"
>
<template #trigger="{ toggle }">
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="openComposeConversationModal(toggle)"
/>
</template>
</ComposeConversation>
<VoiceCallButton
:phone="contact.phone_number"
:contact-id="contact.id"
icon="i-ri-phone-fill"
size="sm"
:tooltip-label="$t('CONTACT_PANEL.CALL')"
slate
faded
/>
<NextButton
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
icon="i-ph-pencil-simple"
slate
faded
sm
@click="toggleEditModal"
/>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
@click="openMergeModal"
/>
<NextButton
v-if="isAdmin"
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
@click="toggleDeleteModal"
/>
</div>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
</div>
<woot-delete-modal
v-if="showDeleteModal"
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="confirmDeleteMessage"
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
/>
</div>
</template>

View File

@@ -0,0 +1,95 @@
<script>
import { useAlert } from 'dashboard/composables';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
},
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
emoji: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
showCopy: {
type: Boolean,
default: false,
},
},
methods: {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
},
};
</script>
<template>
<div class="w-full h-5 ltr:-ml-1 rtl:-mr-1">
<a
v-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
:title="value"
>
{{ value }}
</span>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="showCopy"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1"
icon="i-lucide-clipboard"
@click="onCopy"
/>
</a>
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<span
v-if="value"
v-dompurify-html="value"
class="overflow-hidden text-sm whitespace-nowrap text-ellipsis"
/>
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,166 @@
<script setup>
import { watch, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
contactId: { type: [String, Number], required: true },
});
const { t } = useI18n();
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const contactId = computed(() => props.contactId);
const noteContent = ref('');
const shouldShowCreateModal = ref(false);
const notes = computed(() => {
if (!contactId.value) {
return [];
}
return notesByContact.value(contactId.value) || [];
});
const getWrittenBy = ({ user } = {}) => {
const currentUserId = currentUser.value?.id;
return user?.id === currentUserId
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: user?.name || t('CONVERSATION.BOT');
};
const openCreateModal = () => {
if (!contactId.value) {
return;
}
noteContent.value = '';
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
noteContent.value = '';
};
const onAdd = async () => {
if (!contactId.value || !noteContent.value || isCreatingNote.value) {
return;
}
await store.dispatch('contactNotes/create', {
content: noteContent.value,
contactId: contactId.value,
});
noteContent.value = '';
closeCreateModal();
};
const onDelete = noteId => {
if (!contactId.value || !noteId) {
return;
}
store.dispatch('contactNotes/delete', {
noteId,
contactId: contactId.value,
});
};
const keyboardEvents = {
'$mod+Enter': {
action: onAdd,
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
watch(
contactId,
newContactId => {
closeCreateModal();
if (newContactId) {
store.dispatch('contactNotes/get', { contactId: newContactId });
}
},
{ immediate: true }
);
</script>
<template>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE')"
:disabled="!contactId || isFetchingNotes"
@click="openCreateModal"
/>
</div>
<div
v-if="isFetchingNotes"
class="flex items-center justify-center py-8 text-n-slate-11"
>
<Spinner />
</div>
<div
v-else-if="notes.length"
class="flex flex-col max-h-[300px] overflow-y-auto"
>
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="py-4 last-of-type:border-b-0 px-4"
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
collapsible
@delete="onDelete"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.CONVERSATION_EMPTY_STATE') }}
</p>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<div class="flex w-full flex-col gap-6 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.ADD_NOTE') }}
</h3>
<Editor
v-model="noteContent"
focus-on-mount
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4"
/>
<div class="flex items-center justify-end gap-3">
<NextButton
solid
blue
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
:is-loading="isCreatingNote"
:disabled="!noteContent || isCreatingNote"
@click="onAdd"
/>
</div>
</div>
</woot-modal>
</div>
</template>

View File

@@ -0,0 +1,74 @@
<script>
import { mapGetters } from 'vuex';
import ContactForm from './ContactForm.vue';
export default {
components: {
ContactForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
await this.$store.dispatch('contacts/update', contactItem);
await this.$store.dispatch(
'contacts/fetchContactableInbox',
this.contact.id
);
},
},
};
</script>
<template>
<woot-modal
v-model:show="localShow"
:on-close="onCancel"
modal-type="right-aligned"
>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="`${$t('EDIT_CONTACT.TITLE')} - ${
contact.name || contact.email
}`"
:header-content="$t('EDIT_CONTACT.DESC')"
/>
<ContactForm
:contact="contact"
:in-progress="uiFlags.isUpdating"
:on-submit="onSubmit"
@success="onSuccess"
@cancel="onCancel"
/>
</div>
</woot-modal>
</template>

View File

@@ -0,0 +1,49 @@
<script>
export default {
props: {
socialProfiles: {
type: Object,
default: () => ({}),
},
},
data() {
return {
socialMediaLinks: [
{ key: 'facebook', icon: 'facebook', link: 'https://facebook.com/' },
{ key: 'twitter', icon: 'twitter', link: 'https://twitter.com/' },
{ key: 'linkedin', icon: 'linkedin', link: 'https://linkedin.com/' },
{ key: 'github', icon: 'github', link: 'https://github.com/' },
{ key: 'instagram', icon: 'instagram', link: 'https://instagram.com/' },
{ key: 'telegram', icon: 'telegram', link: 'https://t.me/' },
{ key: 'tiktok', icon: 'tiktok', link: 'https://tiktok.com/@' },
],
};
},
computed: {
availableProfiles() {
return this.socialMediaLinks.filter(
mediaLink => !!this.socialProfiles[mediaLink.key]
);
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="availableProfiles.length" class="flex items-end gap-3 mx-0 my-2">
<a
v-for="profile in availableProfiles"
:key="profile.key"
:href="`${profile.link}${socialProfiles[profile.key]}`"
target="_blank"
rel="noopener noreferrer nofollow"
>
<fluent-icon
:icon="`brand-${profile.key}`"
size="16"
class="text-n-slate-11 hover:text-n-slate-10"
/>
</a>
</div>
</template>

View File

@@ -0,0 +1,203 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import ConversationView from './ConversationView.vue';
const CONVERSATION_PERMISSIONS = [
'administrator',
'agent',
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
];
export default {
routes: [
{
path: frontendURL('accounts/:accountId/dashboard'),
name: 'home',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => {
return { inboxId: 0 };
},
},
{
path: frontendURL('accounts/:accountId/conversations/:conversation_id'),
name: 'inbox_conversation',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => {
return { inboxId: 0, conversationId: route.params.conversation_id };
},
},
{
path: frontendURL('accounts/:accountId/inbox/:inbox_id'),
name: 'inbox_dashboard',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => {
return { inboxId: route.params.inbox_id };
},
},
{
path: frontendURL(
'accounts/:accountId/inbox/:inbox_id/conversations/:conversation_id'
),
name: 'conversation_through_inbox',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => {
return {
conversationId: route.params.conversation_id,
inboxId: route.params.inbox_id,
};
},
},
{
path: frontendURL('accounts/:accountId/label/:label'),
name: 'label_conversations',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({ label: route.params.label }),
},
{
path: frontendURL(
'accounts/:accountId/label/:label/conversations/:conversation_id'
),
name: 'conversations_through_label',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
conversationId: route.params.conversation_id,
label: route.params.label,
}),
},
{
path: frontendURL('accounts/:accountId/team/:teamId'),
name: 'team_conversations',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({ teamId: route.params.teamId }),
},
{
path: frontendURL(
'accounts/:accountId/team/:teamId/conversations/:conversationId'
),
name: 'conversations_through_team',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
teamId: route.params.teamId,
}),
},
{
path: frontendURL('accounts/:accountId/custom_view/:id'),
name: 'folder_conversations',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
{
path: frontendURL(
'accounts/:accountId/custom_view/:id/conversations/:conversation_id'
),
name: 'conversations_through_folders',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
conversationId: route.params.conversation_id,
foldersId: route.params.id,
}),
},
{
path: frontendURL('accounts/:accountId/mentions/conversations'),
name: 'conversation_mentions',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => ({ conversationType: 'mention' }),
},
{
path: frontendURL(
'accounts/:accountId/mentions/conversations/:conversationId'
),
name: 'conversation_through_mentions',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
conversationType: 'mention',
}),
},
{
path: frontendURL('accounts/:accountId/unattended/conversations'),
name: 'conversation_unattended',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => ({ conversationType: 'unattended' }),
},
{
path: frontendURL(
'accounts/:accountId/unattended/conversations/:conversationId'
),
name: 'conversation_through_unattended',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
conversationType: 'unattended',
}),
},
{
path: frontendURL('accounts/:accountId/participating/conversations'),
name: 'conversation_participating',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: () => ({ conversationType: 'participating' }),
},
{
path: frontendURL(
'accounts/:accountId/participating/conversations/:conversationId'
),
name: 'conversation_through_participating',
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
props: route => ({
conversationId: route.params.conversationId,
conversationType: 'participating',
}),
},
],
};

View File

@@ -0,0 +1,333 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import Draggable from 'vuedraggable';
import { useToggle } from '@vueuse/core';
import { useRoute } from 'vue-router';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attributeType: {
type: String,
default: 'conversation_attribute',
},
contactId: { type: Number, default: null },
attributeFrom: {
type: String,
required: true,
},
emptyStateMessage: {
type: String,
default: '',
},
// Combine static elements with custom attributes components
// To allow for custom ordering
staticElements: {
type: Array,
default: () => [],
},
});
const store = useStore();
const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
const dragging = ref(false);
const [showAllAttributes, toggleShowAllAttributes] = useToggle(false);
const currentChat = computed(() => getters.getSelectedChat.value);
const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(props.attributeType)
);
const contactIdentifier = computed(
() =>
currentChat.value.meta?.sender?.id ||
route.params.contactId ||
props.contactId
);
const contact = computed(() =>
getters['contacts/getContact'].value(contactIdentifier.value)
);
const customAttributes = computed(() => {
if (props.attributeType === 'conversation_attribute')
return currentChat.value.custom_attributes || {};
return contact.value.custom_attributes || {};
});
const conversationId = computed(() => currentChat.value.id);
const toggleButtonText = computed(() =>
!showAllAttributes.value
? t('CUSTOM_ATTRIBUTES.SHOW_MORE')
: t('CUSTOM_ATTRIBUTES.SHOW_LESS')
);
const filteredCustomAttributes = computed(() =>
attributes.value.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = attribute.attribute_key in customAttributes.value;
return {
...attribute,
type: 'custom_attribute',
key: attribute.attribute_key,
// Set value from customAttributes if it exists, otherwise use ''
value: hasValue ? customAttributes.value[attribute.attribute_key] : '',
};
})
);
// Order key name for UI settings
const orderKey = computed(
() => `conversation_elements_order_${props.attributeFrom}`
);
const combinedElements = computed(() => {
// Get saved order from UI settings
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const allElements = [
...props.staticElements,
...filteredCustomAttributes.value,
];
// If no saved order exists, return in default order
if (!savedOrder.length) return allElements;
return allElements.sort((a, b) => {
// Find positions of elements in saved order
const aPosition = savedOrder.indexOf(a.key);
const bPosition = savedOrder.indexOf(b.key);
// Handle cases where elements are not in saved order:
// - New elements (not in saved order) go to the end
// - If both elements are new, maintain their relative order
if (aPosition === -1 && bPosition === -1) return 0;
if (aPosition === -1) return 1;
if (bPosition === -1) return -1;
return aPosition - bPosition;
});
});
const displayedElements = computed(() => {
if (showAllAttributes.value || combinedElements.value.length <= 5) {
return combinedElements.value;
}
// Show first 5 elements in the order they appear
return combinedElements.value.slice(0, 5);
});
// Reorder elements with static elements position preserved
// There is case where all the static elements will not be available (API, Email channels, etc).
// In that case, we need to preserve the order of the static elements and
// insert them in the correct position.
const reorderElementsWithStaticPreservation = (
savedOrder = [],
currentOrder = []
) => {
const finalOrder = [...currentOrder];
const visibleKeys = new Set(currentOrder);
// Process hidden static elements from saved order
savedOrder
// Find static elements that aren't currently visible
.filter(key => key.startsWith('static-') && !visibleKeys.has(key))
.forEach(staticKey => {
// Find next visible element after this static element in saved order
const nextVisible = savedOrder
.slice(savedOrder.indexOf(staticKey))
.find(key => visibleKeys.has(key));
// If next visible element found, insert before it; otherwise add to end
if (nextVisible) {
finalOrder.splice(finalOrder.indexOf(nextVisible), 0, staticKey);
} else {
finalOrder.push(staticKey);
}
});
return finalOrder;
};
const onDragEnd = () => {
dragging.value = false;
// Get the saved and current saved order
const savedOrder = uiSettings.value[orderKey.value] ?? [];
const currentOrder = combinedElements.value.map(({ key }) => key);
const finalOrder = reorderElementsWithStaticPreservation(
savedOrder,
currentOrder
);
updateUISettings({
[orderKey.value]: finalOrder,
});
};
const initializeSettings = () => {
const currentOrder = uiSettings.value[orderKey.value];
if (!currentOrder) {
const initialOrder = combinedElements.value.map(element => element.key);
updateUISettings({
[orderKey.value]: initialOrder,
});
}
showAllAttributes.value =
uiSettings.value[`show_all_attributes_${props.attributeFrom}`] || false;
};
const onClickToggle = () => {
toggleShowAllAttributes();
updateUISettings({
[`show_all_attributes_${props.attributeFrom}`]: showAllAttributes.value,
});
};
const onUpdate = async (key, value) => {
const updatedAttributes = { ...customAttributes.value, [key]: value };
try {
if (props.attributeType === 'conversation_attribute') {
await store.dispatch('updateCustomAttributes', {
conversationId: conversationId.value,
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/update', {
id: props.contactId,
customAttributes: updatedAttributes,
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message || t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR');
useAlert(errorMessage);
}
};
const onDelete = async key => {
try {
const { [key]: remove, ...updatedAttributes } = customAttributes.value;
if (props.attributeType === 'conversation_attribute') {
await store.dispatch('updateCustomAttributes', {
conversationId: conversationId.value,
customAttributes: updatedAttributes,
});
} else {
store.dispatch('contacts/deleteCustomAttributes', {
id: props.contactId,
customAttributes: [key],
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message || t('CUSTOM_ATTRIBUTES.FORM.DELETE.ERROR');
useAlert(errorMessage);
}
};
const onCopy = async attributeValue => {
await copyTextToClipboard(attributeValue);
useAlert(t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
};
onMounted(() => {
initializeSettings();
});
const evenClass = [
'[&>*:nth-child(odd)]:!bg-n-surface-1 [&>*:nth-child(even)]:!bg-n-slate-1',
'dark:[&>*:nth-child(odd)]:!bg-n-surface-2 dark:[&>*:nth-child(even)]:!bg-n-surface-1',
];
</script>
<template>
<div class="conversation--details">
<Draggable
:list="displayedElements"
:disabled="!showAllAttributes"
animation="200"
ghost-class="ghost"
handle=".drag-handle"
item-key="key"
class="last:rounded-b-lg"
:class="evenClass"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div
class="drag-handle relative border-b border-n-weak/50 dark:border-n-weak/90"
:class="{
'cursor-grab': showAllAttributes,
'last:border-transparent dark:last:border-transparent':
combinedElements.length <= 5,
}"
>
<template v-if="element.type === 'static_attribute'">
<slot name="staticItem" :element="element" />
</template>
<template v-else>
<CustomAttribute
:key="element.id"
:attribute-key="element.attribute_key"
:attribute-type="element.attribute_display_type"
:values="element.attribute_values"
:label="element.attribute_display_name"
:description="element.attribute_description"
:value="element.value"
show-actions
:attribute-regex="element.regex_pattern"
:regex-cue="element.regex_cue"
:contact-id="contactId"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
/>
</template>
</div>
</template>
</Draggable>
<p
v-if="!displayedElements.length && emptyStateMessage"
class="p-3 text-center"
>
{{ emptyStateMessage }}
</p>
<!-- Show more and show less buttons show it if the combinedElements length is greater than 5 -->
<div v-if="combinedElements.length > 5" class="flex items-center px-2 py-2">
<NextButton
ghost
xs
:icon="
showAllAttributes ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'
"
:label="toggleButtonText"
@click="onClickToggle"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.ghost {
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
}
</style>

View File

@@ -0,0 +1,139 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Spinner from 'shared/components/Spinner.vue';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
export default {
components: {
Spinner,
LabelDropdown,
AddLabel,
},
setup() {
const { isAdmin } = useAdmin();
const {
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const showSearchDropdownLabel = ref(false);
const toggleLabels = () => {
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
};
const closeDropdownLabel = () => {
showSearchDropdownLabel.value = false;
};
const keyboardEvents = {
KeyL: {
action: e => {
e.preventDefault();
toggleLabels();
},
},
Escape: {
action: () => {
if (showSearchDropdownLabel.value) {
toggleLabels();
}
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
isAdmin,
savedLabels,
activeLabels,
accountLabels,
addLabelToConversation,
removeLabelFromConversation,
showSearchDropdownLabel,
closeDropdownLabel,
toggleLabels,
};
},
data() {
return {
selectedLabels: [],
};
},
computed: {
...mapGetters({
conversationUiFlags: 'conversationLabels/getUIFlags',
}),
},
};
</script>
<template>
<div class="sidebar-labels-wrap">
<div
v-if="!conversationUiFlags.isFetching"
class="contact-conversation--list"
>
<div
v-on-clickaway="closeDropdownLabel"
class="label-wrap flex flex-wrap"
@keyup.esc="closeDropdownLabel"
>
<AddLabel @add="toggleLabels" />
<woot-label
v-for="label in activeLabels"
:key="label.id"
:title="label.title"
:description="label.description"
show-close
:color="label.color"
variant="smooth"
class="max-w-[calc(100%-0.5rem)]"
@remove="removeLabelFromConversation"
/>
<div
:class="{
'block visible': showSearchDropdownLabel,
'hidden invisible': !showSearchDropdownLabel,
}"
class="border rounded-lg bg-n-alpha-3 top-6 backdrop-blur-[100px] absolute w-full shadow-lg border-n-strong dark:border-n-strong p-2 box-border z-[9999]"
>
<LabelDropdown
v-if="showSearchDropdownLabel"
:account-labels="accountLabels"
:selected-labels="savedLabels"
:allow-creation="isAdmin"
@add="addLabelToConversation"
@remove="removeLabelFromConversation"
/>
</div>
</div>
</div>
<Spinner v-else />
</div>
</template>
<style lang="scss" scoped>
.sidebar-labels-wrap {
margin-bottom: 0;
}
.contact-conversation--list {
width: 100%;
.label-wrap {
line-height: 1.5rem;
position: relative;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
isOnExpandedLayout: {
type: Boolean,
default: false,
},
},
emits: ['toggle'],
methods: {
toggle() {
this.$emit('toggle');
},
},
};
</script>
<template>
<NextButton
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
:icon="
isOnExpandedLayout
? 'i-lucide-arrow-left-to-line'
: 'i-lucide-arrow-right-to-line'
"
slate
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
@click="toggle"
/>
</template>

View File

@@ -0,0 +1,114 @@
<script>
import { useAlert } from 'dashboard/composables';
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { useTrack } from 'dashboard/composables';
export default {
props: {
show: {
type: Boolean,
default: false,
},
activeCustomView: {
type: Object,
default: () => {},
},
customViewsId: {
type: [String, Number],
default: 0,
},
activeFilterType: {
type: Number,
default: 0,
},
openLastItemAfterDelete: {
type: Function,
default: () => {},
},
},
emits: ['close', 'update:show'],
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
activeCustomViews() {
if (this.activeFilterType === 0) {
return 'conversation';
}
if (this.activeFilterType === 1) {
return 'contact';
}
return '';
},
deleteMessage() {
return ` ${this.activeCustomView && this.activeCustomView.name}?`;
},
deleteConfirmText() {
return `${this.$t('FILTER.CUSTOM_VIEWS.DELETE.MODAL.CONFIRM.YES')}`;
},
deleteRejectText() {
return `${this.$t('FILTER.CUSTOM_VIEWS.DELETE.MODAL.CONFIRM.NO')}`;
},
isFolderSection() {
return this.activeFilterType === 0 && this.$route.name !== 'home';
},
isSegmentSection() {
return (
this.activeFilterType === 1 && this.$route.name !== 'contacts_dashboard'
);
},
},
methods: {
async deleteSavedCustomViews() {
try {
const id = Number(this.customViewsId);
const filterType = this.activeCustomViews;
await this.$store.dispatch('customViews/delete', { id, filterType });
this.closeDeletePopup();
useAlert(
this.activeFilterType === 0
? this.$t('FILTER.CUSTOM_VIEWS.DELETE.API_FOLDERS.SUCCESS_MESSAGE')
: this.$t('FILTER.CUSTOM_VIEWS.DELETE.API_SEGMENTS.SUCCESS_MESSAGE')
);
useTrack(CONTACTS_EVENTS.DELETE_FILTER, {
type: this.filterType === 0 ? 'folder' : 'segment',
});
} catch (error) {
const errorMessage =
error?.response?.message || this.activeFilterType === 0
? this.$t('FILTER.CUSTOM_VIEWS.DELETE.API_FOLDERS.SUCCESS_MESSAGE')
: this.$t(
'FILTER.CUSTOM_VIEWS.DELETE.API_SEGMENTS.SUCCESS_MESSAGE'
);
useAlert(errorMessage);
}
this.openLastItemAfterDelete();
},
closeDeletePopup() {
this.$emit('close');
},
},
};
</script>
<template>
<div>
<woot-delete-modal
v-if="localShow"
v-model:show="localShow"
:on-close="closeDeletePopup"
:on-confirm="deleteSavedCustomViews"
:title="$t('FILTER.CUSTOM_VIEWS.DELETE.MODAL.CONFIRM.TITLE')"
:message="$t('FILTER.CUSTOM_VIEWS.DELETE.MODAL.CONFIRM.MESSAGE')"
:message-value="deleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
</template>

View File

@@ -0,0 +1,48 @@
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
import { routes as contactRoutes } from './contacts/routes';
import { routes as companyRoutes } from './companies/routes';
import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
import { routes as captainRoutes } from './captain/captain.routes';
import AppContainer from './Dashboard.vue';
import Suspended from './suspended/Index.vue';
import NoAccounts from './noAccounts/Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId'),
component: AppContainer,
children: [
...captainRoutes,
...inboxRoutes,
...conversation.routes,
...settings.routes,
...contactRoutes,
...companyRoutes,
...searchRoutes,
...notificationRoutes,
...helpcenterRoutes.routes,
...campaignsRoutes.routes,
],
},
{
path: frontendURL('accounts/:accountId/suspended'),
name: 'account_suspended',
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: Suspended,
},
{
path: frontendURL('no-accounts'),
name: 'no_accounts',
component: NoAccounts,
},
],
};

View File

@@ -0,0 +1,91 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
id: {
type: Number,
default: 0,
},
title: {
type: String,
default: 'Untitled',
},
url: {
type: String,
default: '',
},
category: {
type: String,
default: '',
},
locale: {
type: String,
default: '',
},
});
const emit = defineEmits(['insert', 'preview']);
const { t } = useI18n();
const handleInsert = e => {
e.stopPropagation();
emit('insert', props.id);
};
const handlePreview = e => {
e.stopPropagation();
emit('preview', props.id);
};
const handleCopy = async e => {
e.stopPropagation();
await copyTextToClipboard(props.url);
useAlert(t('CONTACT_PANEL.COPY_SUCCESSFUL'));
};
</script>
<template>
<button
class="flex flex-col w-full gap-1 px-2 py-1 border border-transparent border-solid rounded-md cursor-pointer hover:bg-n-slate-3 group focus:outline-none focus:bg-n-slate-3"
@click="handlePreview"
>
<h4
class="w-full mb-0 -mx-1 text-sm rounded-sm ltr:text-left rtl:text-right text-n-slate-12 hover:underline group-hover:underline"
>
{{ title }}
</h4>
<div class="flex content-between items-center gap-0.5 w-full">
<p
class="w-full mb-0 text-sm ltr:text-left rtl:text-right text-n-slate-11"
>
{{ locale }}
{{ ` / ` }}
{{ category || $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.UNCATEGORIZED') }}
</p>
<div class="flex gap-0.5">
<Button
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.COPY_LINK')"
faded
slate
xs
type="reset"
icon="i-lucide-copy"
class="invisible group-hover:visible"
@click="handleCopy"
/>
<Button
xs
faded
slate
:label="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE')"
@click="handleInsert"
/>
</div>
</div>
</button>
</template>

View File

@@ -0,0 +1,64 @@
<script setup>
import IframeLoader from 'shared/components/IframeLoader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useMapGetter } from 'dashboard/composables/store';
defineProps({
url: {
type: String,
default: '',
},
});
const emit = defineEmits(['back', 'insert']);
const isRTL = useMapGetter('accounts/isRTL');
const onBack = e => {
e.stopPropagation();
emit('back');
};
const onInsert = e => {
e.stopPropagation();
emit('insert');
};
</script>
<template>
<div class="h-full w-full flex flex-col flex-1 overflow-hidden">
<div class="py-1">
<Button
link
xs
:label="$t('HELP_CENTER.ARTICLE_SEARCH.BACK_RESULTS')"
icon="i-lucide-chevron-left"
@click="onBack"
/>
</div>
<div class="-ml-4 h-full overflow-y-auto">
<div class="w-full h-full min-h-0">
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
</div>
</div>
<div class="flex justify-end gap-2 py-2">
<Button
faded
slate
sm
type="reset"
icon="i-lucide-chevron-left"
:label="$t('HELP_CENTER.ARTICLE_SEARCH.BACK')"
@click="onBack"
/>
<Button
sm
type="submit"
icon="i-lucide-arrow-down-to-dot"
:label="$t('HELP_CENTER.ARTICLE_SEARCH.INSERT_ARTICLE')"
@click="onInsert"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,72 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
title: {
type: String,
default: 'Chatwoot',
},
});
const emit = defineEmits(['search', 'close']);
const searchInputRef = ref(null);
const searchQuery = ref('');
onMounted(() => {
searchInputRef.value.focus();
});
const onInput = e => {
emit('search', e.target.value);
};
const onClose = () => {
emit('close');
};
const keyboardEvents = {
Slash: {
action: e => {
e.preventDefault();
searchInputRef.value.focus();
},
},
Escape: {
action: () => {
onClose();
},
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col py-1">
<div class="flex items-center justify-between py-2 mb-1">
<h3 class="text-base text-n-slate-12">
{{ title }}
</h3>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="relative">
<div
class="absolute ltr:left-0 rtl:right-0 w-8 top-0.5 h-8 flex justify-center items-center"
>
<fluent-icon icon="search" class="" size="18" />
</div>
<input
ref="searchInputRef"
type="text"
:placeholder="$t('HELP_CENTER.ARTICLE_SEARCH.PLACEHOLDER')"
class="block w-full !h-9 ltr:!pl-8 rtl:!pr-8 dark:!bg-n-slate-2 !border-n-weak !bg-n-slate-2 text-sm rounded-md leading-8 text-n-slate-12 shadow-sm ring-2 ring-transparent ring-n-weak border border-solid placeholder:text-n-slate-10 focus:border-n-brand focus:ring-n-brand !mb-0"
:value="searchQuery"
@input="onInput"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,166 @@
<script>
import { debounce } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import allLocales from 'shared/constants/locales.js';
import SearchHeader from './Header.vue';
import SearchResults from './SearchResults.vue';
import ArticleView from './ArticleView.vue';
import ArticlesAPI from 'dashboard/api/helpCenter/articles';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
export default {
name: 'ArticleSearchPopover',
components: {
SearchHeader,
SearchResults,
ArticleView,
},
props: {
selectedPortalSlug: {
type: String,
required: true,
},
},
emits: ['close', 'insert'],
data() {
return {
searchQuery: '',
isLoading: false,
searchResults: [],
activeId: '',
debounceSearch: () => {},
};
},
computed: {
...mapGetters({
portalBySlug: 'portals/portalBySlug',
}),
portal() {
return this.portalBySlug(this.selectedPortalSlug);
},
portalCustomDomain() {
return this.portal?.custom_domain;
},
articleViewerUrl() {
const article = this.activeArticle(this.activeId);
if (!article) return '';
const isDark = document.body.classList.contains('dark');
const url = new URL(article.url);
url.searchParams.set('show_plain_layout', 'true');
if (isDark) {
url.searchParams.set('theme', 'dark');
}
return `${url}`;
},
searchResultsWithUrl() {
return this.searchResults.map(article => ({
...article,
localeName: this.localeName(article.category.locale || 'en'),
url: this.generateArticleUrl(article),
}));
},
},
mounted() {
this.fetchArticlesByQuery(this.searchQuery);
this.debounceSearch = debounce(this.fetchArticlesByQuery, 500, false);
},
methods: {
generateArticleUrl(article) {
return buildPortalArticleURL(
this.selectedPortalSlug,
'',
'',
article.slug,
this.portalCustomDomain
);
},
localeName(code) {
return allLocales[code];
},
activeArticle(id) {
return this.searchResultsWithUrl.find(article => article.id === id);
},
onSearch(query) {
this.searchQuery = query;
this.activeId = '';
this.debounceSearch(query);
},
onClose() {
this.$emit('close');
this.searchQuery = '';
this.activeId = '';
this.searchResults = [];
},
async fetchArticlesByQuery(query) {
try {
const sort = query ? '' : 'views';
this.isLoading = true;
this.searchResults = [];
const { data } = await ArticlesAPI.searchArticles({
portalSlug: this.selectedPortalSlug,
query,
sort,
});
this.searchResults = data.payload;
this.isLoading = true;
} catch (error) {
// Show something wrong message
} finally {
this.isLoading = false;
}
},
handlePreview(id) {
this.activeId = id;
},
onBack() {
this.activeId = '';
},
onInsert(id) {
const article = this.activeArticle(id || this.activeId);
this.$emit('insert', article);
useAlert(this.$t('HELP_CENTER.ARTICLE_SEARCH.SUCCESS_ARTICLE_INSERTED'));
this.onClose();
},
},
};
</script>
<template>
<div
class="fixed top-0 left-0 z-50 flex items-center justify-center w-screen h-screen bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
>
<div
v-on-clickaway="onClose"
class="flex flex-col px-4 pb-4 rounded-md shadow-md border border-solid border-n-weak bg-n-background z-[1000] max-w-[720px] md:w-[20rem] lg:w-[24rem] xl:w-[28rem] 2xl:w-[32rem] h-[calc(100vh-20rem)] max-h-[40rem]"
>
<SearchHeader
:title="$t('HELP_CENTER.ARTICLE_SEARCH.TITLE')"
class="w-full sticky top-0 bg-[inherit]"
@close="onClose"
@search="onSearch"
/>
<ArticleView
v-if="activeId"
:url="articleViewerUrl"
@back="onBack"
@insert="onInsert"
/>
<SearchResults
v-else
:search-query="searchQuery"
:is-loading="isLoading"
:portal-slug="selectedPortalSlug"
:articles="searchResultsWithUrl"
@preview="handlePreview"
@insert="onInsert"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,73 @@
<script>
import SearchResultItem from './ArticleSearchResultItem.vue';
export default {
name: 'SearchResults',
components: {
SearchResultItem,
},
props: {
articles: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
searchQuery: {
type: String,
default: '',
},
},
emits: ['preview', 'insert'],
computed: {
showNoResults() {
return this.searchQuery && this.articles.length === 0;
},
},
methods: {
handlePreview(id) {
this.$emit('preview', id);
},
handleInsert(id) {
this.$emit('insert', id);
},
},
};
</script>
<template>
<div
class="flex justify-end h-full gap-1 py-4 overflow-y-auto bg-n-background"
>
<div class="flex flex-col w-full gap-1">
<div
v-if="isLoading"
class="text-center flex items-center justify-center px-4 py-8 text-n-slate-10 text-sm"
>
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.SEARCH_LOADER') }}
</div>
<div
v-else-if="showNoResults"
class="text-center flex items-center justify-center px-4 py-8 text-n-slate-10 text-sm"
>
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.NO_RESULT') }}
</div>
<template v-else>
<SearchResultItem
v-for="article in articles"
:id="article.id"
:key="article.id"
:title="article.title"
:body="article.content"
:url="article.url"
:category="article.category.name"
:locale="article.localeName"
@preview="handlePreview"
@insert="handleInsert"
/>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script>
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
data() {
return {
helpCenterDocsURL: wootConstants.HELP_CENTER_DOCS_URL,
upgradeFeature: [
{
key: 1,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.PORTALS.TITLE'),
icon: 'book-copy',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.PORTALS.DESCRIPTION'
),
},
{
key: 2,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.LOCALES.TITLE'),
icon: 'globe-line',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.LOCALES.DESCRIPTION'
),
},
{
key: 3,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.SEO.TITLE'),
icon: 'heart-handshake',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.SEO.DESCRIPTION'
),
},
{
key: 4,
title: this.$t('HELP_CENTER.UPGRADE_PAGE.FEATURES.API.TITLE'),
icon: 'search-check',
description: this.$t(
'HELP_CENTER.UPGRADE_PAGE.FEATURES.API.DESCRIPTION'
),
},
],
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', // Pending change text
}),
},
methods: {
openBillingPage() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
openHelpCenterDocs() {
window.open(this.helpCenterDocsURL, '_blank');
},
},
};
</script>
<template>
<div
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 w-full min-h-screen max-w-full overflow-auto bg-n-surface-1"
>
<div class="flex flex-col justify-start sm:justify-center gap-6">
<div class="flex flex-col gap-1.5 items-start sm:items-center">
<h1
class="text-n-slate-12 text-left sm:text-center text-4xl sm:text-5xl mb-6 font-semibold"
>
{{ $t('HELP_CENTER.UPGRADE_PAGE.TITLE') }}
</h1>
<p
class="max-w-2xl text-base font-normal leading-6 text-left sm:text-center text-n-slate-11"
>
{{
isOnChatwootCloud
? $t('HELP_CENTER.UPGRADE_PAGE.DESCRIPTION')
: $t('HELP_CENTER.UPGRADE_PAGE.SELF_HOSTED_DESCRIPTION')
}}
</p>
</div>
<div
v-if="isOnChatwootCloud"
class="flex flex-row gap-3 justify-start items-center sm:justify-center"
>
<NextButton
outline
:label="$t('HELP_CENTER.UPGRADE_PAGE.BUTTON.LEARN_MORE')"
@click="openHelpCenterDocs"
/>
<NextButton
:label="$t('HELP_CENTER.UPGRADE_PAGE.BUTTON.UPGRADE')"
@click="openBillingPage"
/>
</div>
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-6 sm:gap-16 w-full max-w-2xl overflow-auto"
>
<div
v-for="feature in upgradeFeature"
:key="feature.key"
class="w-64 min-w-full"
>
<div class="flex gap-2 flex-row">
<div>
<fluent-icon
:icon="feature.icon"
icon-lib="lucide"
:size="26"
class="mt-px text-n-slate-12"
/>
</div>
<div>
<h5 class="font-semibold text-lg text-n-slate-12">
{{ feature.title }}
</h5>
<p class="text-sm leading-6 text-n-slate-12">
{{ feature.description }}
</p>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,122 @@
import { FEATURE_FLAGS } from '../../../featureFlags';
import { getPortalRoute } from './helpers/routeHelper';
import HelpCenterPageRouteView from './pages/HelpCenterPageRouteView.vue';
import PortalsIndex from './pages/PortalsIndexPage.vue';
import PortalsNew from './pages/PortalsNewPage.vue';
const PortalsArticlesIndexPage = () =>
import('./pages/PortalsArticlesIndexPage.vue');
const PortalsArticlesNewPage = () =>
import('./pages/PortalsArticlesNewPage.vue');
const PortalsArticlesEditPage = () =>
import('./pages/PortalsArticlesEditPage.vue');
const PortalsCategoriesIndexPage = () =>
import('./pages/PortalsCategoriesIndexPage.vue');
const PortalsLocalesIndexPage = () =>
import('./pages/PortalsLocalesIndexPage.vue');
const PortalsSettingsIndexPage = () =>
import('./pages/PortalsSettingsIndexPage.vue');
const meta = {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
};
const portalRoutes = [
{
path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/:tab?'),
name: 'portals_articles_index',
meta,
component: PortalsArticlesIndexPage,
},
{
path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/new'),
name: 'portals_articles_new',
meta,
component: PortalsArticlesNewPage,
},
{
path: getPortalRoute(
':portalSlug/:locale/:categorySlug?/articles/:tab?/edit/:articleSlug'
),
name: 'portals_articles_edit',
meta,
component: PortalsArticlesEditPage,
},
{
path: getPortalRoute(':portalSlug/:locale/categories'),
name: 'portals_categories_index',
meta,
component: PortalsCategoriesIndexPage,
},
{
path: getPortalRoute(
':portalSlug/:locale/categories/:categorySlug/articles'
),
name: 'portals_categories_articles_index',
meta,
component: PortalsArticlesIndexPage,
},
{
path: getPortalRoute(
':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug'
),
name: 'portals_categories_articles_edit',
meta,
component: PortalsArticlesEditPage,
},
{
path: getPortalRoute(':portalSlug/locales'),
name: 'portals_locales_index',
meta,
component: PortalsLocalesIndexPage,
},
{
path: getPortalRoute(':portalSlug/settings'),
name: 'portals_settings_index',
meta,
component: PortalsSettingsIndexPage,
},
{
path: getPortalRoute('new'),
name: 'portals_new',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
component: PortalsNew,
},
{
path: getPortalRoute(':navigationPath'),
name: 'portals_index',
meta: {
featureFlag: FEATURE_FLAGS.HELP_CENTER,
permissions: ['administrator', 'knowledge_base_manage'],
},
component: PortalsIndex,
},
];
export default {
routes: [
{
path: getPortalRoute(),
component: HelpCenterPageRouteView,
redirect: to => {
return {
name: 'portals_index',
params: {
navigationPath: 'portals_articles_index',
...to.params,
},
};
},
children: [...portalRoutes],
},
],
};

View File

@@ -0,0 +1,6 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
export const getPortalRoute = (path = '') => {
const slugToBeAdded = path ? `/${path}` : '';
return frontendURL(`accounts/:accountId/portals${slugToBeAdded}`);
};

View File

@@ -0,0 +1,10 @@
import { getPortalRoute } from '../routeHelper';
describe('', () => {
it('returns correct portal URL', () => {
expect(getPortalRoute('')).toEqual('/app/accounts/:accountId/portals');
expect(getPortalRoute(':portalSlug')).toEqual(
'/app/accounts/:accountId/portals/:portalSlug'
);
});
});

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import UpgradePage from '../components/UpgradePage.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const accountId = computed(() => store.getters.getCurrentAccountId);
const portals = computed(() => store.getters['portals/allPortals']);
const isFeatureEnabledonAccount = (id, flag) =>
store.getters['accounts/isFeatureEnabledonAccount'](id, flag);
const isHelpCenterEnabled = computed(() =>
isFeatureEnabledonAccount(accountId.value, FEATURE_FLAGS.HELP_CENTER)
);
const selectedPortal = computed(() => {
const slug =
route.params.portalSlug || uiSettings.value.last_active_portal_slug;
if (slug) return store.getters['portals/portalBySlug'](slug);
return portals.value[0];
});
const defaultPortalLocale = computed(() =>
selectedPortal.value ? selectedPortal.value.meta?.default_locale : ''
);
const selectedLocaleInPortal = computed(
() => route.params.locale || defaultPortalLocale.value
);
const selectedPortalSlug = computed(() =>
selectedPortal.value ? selectedPortal.value.slug : ''
);
const fetchPortalAndItsCategories = async () => {
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: selectedPortalSlug.value,
locale: selectedLocaleInPortal.value,
};
store.dispatch('portals/show', selectedPortalParam);
store.dispatch('categories/index', selectedPortalParam);
store.dispatch('agents/get');
};
onMounted(() => fetchPortalAndItsCategories());
watch(
() => route.params.portalSlug,
newSlug => {
if (newSlug && newSlug !== uiSettings.value.last_active_portal_slug) {
updateUISettings({
last_active_portal_slug: newSlug,
last_active_locale_code: selectedLocaleInPortal.value,
});
}
}
);
</script>
<template>
<div class="flex w-full h-full min-h-0">
<section
v-if="isHelpCenterEnabled"
class="flex flex-1 h-full px-0 overflow-hidden bg-n-surface-1"
>
<router-view />
</section>
<UpgradePage v-else />
</div>
</template>

View File

@@ -0,0 +1,119 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { articleSlug, portalSlug } = route.params;
const articleById = useMapGetter('articles/articleById');
const article = computed(() => articleById.value(articleSlug));
const portalBySlug = useMapGetter('portals/portalBySlug');
const portal = computed(() => portalBySlug.value(portalSlug));
const isUpdating = ref(false);
const isSaved = ref(false);
const articleLink = computed(() => {
const { slug: categorySlug, locale: categoryLocale } = article.value.category;
const { slug: articleSlugValue } = article.value;
const portalCustomDomain = portal.value?.custom_domain;
return buildPortalArticleURL(
portalSlug,
categorySlug,
categoryLocale,
articleSlugValue,
portalCustomDomain
);
});
const saveArticle = async ({ ...values }, isAsync = false) => {
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
isUpdating.value = true;
try {
await store.dispatch(actionToDispatch, {
portalSlug,
articleId: articleSlug,
...values,
});
isSaved.value = true;
} catch (error) {
const errorMessage =
error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR');
useAlert(errorMessage);
} finally {
setTimeout(() => {
isUpdating.value = false;
isSaved.value = true;
}, 1500);
}
};
const saveArticleAsync = async ({ ...values }) => {
saveArticle({ ...values }, true);
};
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
route.name === 'portals_categories_articles_edit' ||
route.name === 'portals_categories_index'
);
});
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
if (isCategoryArticles.value) {
router.push({
name: 'portals_categories_articles_index',
params: { categorySlug, locale },
});
} else {
router.push({
name: 'portals_articles_index',
params: { tab, categorySlug, locale },
});
}
};
const fetchArticleDetails = () => {
store.dispatch('articles/show', {
id: articleSlug,
portalSlug,
});
};
const previewArticle = () => {
window.open(articleLink.value, '_blank');
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
status: article.value?.status,
});
};
onMounted(fetchArticleDetails);
</script>
<template>
<ArticleEditor
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="saveArticle"
@save-article-async="saveArticleAsync"
@preview-article="previewArticle"
@go-back="goBackToArticles"
/>
</template>

View File

@@ -0,0 +1,116 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
import ArticlesPage from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticlesPage.vue';
const route = useRoute();
const store = useStore();
const pageNumber = ref(1);
const articles = useMapGetter('articles/allArticles');
const categories = useMapGetter('categories/allCategories');
const meta = useMapGetter('articles/getMeta');
const portalMeta = useMapGetter('portals/getMeta');
const currentUserId = useMapGetter('getCurrentUserID');
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const selectedCategorySlug = computed(() => route.params.categorySlug);
const status = computed(() => getArticleStatus(route.params.tab));
const author = computed(() =>
route.params.tab === 'mine' ? currentUserId.value : null
);
const activeLocale = computed(() => route.params.locale);
const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
});
const defaultPortalLocale = computed(() => {
return portal.value?.meta?.default_locale;
});
const selectedLocaleInPortal = computed(() => {
return route.params.locale || defaultPortalLocale.value;
});
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
route.name === 'portals_categories_articles_edit' ||
route.name === 'portals_categories_index'
);
});
const fetchArticles = ({ pageNumber: pageNumberParam } = {}) => {
store.dispatch('articles/index', {
pageNumber: pageNumberParam || pageNumber.value,
portalSlug: selectedPortalSlug.value,
locale: activeLocale.value,
status: status.value,
authorId: author.value,
categorySlug: selectedCategorySlug.value,
});
};
const onPageChange = pageNumberParam => {
fetchArticles({ pageNumber: pageNumberParam });
};
const fetchPortalAndItsCategories = async locale => {
await store.dispatch('portals/index');
const selectedPortalParam = {
portalSlug: selectedPortalSlug.value,
locale: locale || selectedLocaleInPortal.value,
};
store.dispatch('portals/show', selectedPortalParam);
store.dispatch('categories/index', selectedPortalParam);
store.dispatch('agents/get');
};
onMounted(() => {
fetchArticles();
});
watch(
() => route.params,
() => {
pageNumber.value = 1;
fetchArticles();
},
{ deep: true, immediate: true }
);
</script>
<template>
<div class="w-full h-full">
<ArticlesPage
v-if="portal"
:articles="articles"
:portal-name="portal.name"
:categories="categories"
:allowed-locales="allowedLocales"
:meta="meta"
:portal-meta="portalMeta"
:is-category-articles="isCategoryArticles"
@page-change="onPageChange"
@fetch-portal="fetchPortalAndItsCategories"
/>
</div>
</template>

View File

@@ -0,0 +1,94 @@
<script setup>
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import ArticleEditor from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditor.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { portalSlug } = route.params;
const selectedAuthorId = ref(null);
const selectedCategoryId = ref(null);
const currentUserId = useMapGetter('getCurrentUserID');
const categories = useMapGetter('categories/allCategories');
const categoryId = computed(() => categories.value[0]?.id || null);
const article = ref({});
const isUpdating = ref(false);
const isSaved = ref(false);
const setAuthorId = authorId => {
selectedAuthorId.value = authorId;
};
const setCategoryId = newCategoryId => {
selectedCategoryId.value = newCategoryId;
};
const createNewArticle = async ({ title, content }) => {
if (title) article.value.title = title;
if (content) article.value.content = content;
if (!article.value.title || !article.value.content) return;
isUpdating.value = true;
try {
const { locale } = route.params;
const articleId = await store.dispatch('articles/create', {
portalSlug,
content: article.value.content,
title: article.value.title,
locale: locale,
authorId: selectedAuthorId.value || currentUserId.value,
categoryId: selectedCategoryId.value || categoryId.value,
});
useTrack(PORTALS_EVENTS.CREATE_ARTICLE, { locale });
router.replace({
name: 'portals_articles_edit',
params: {
articleSlug: articleId,
portalSlug,
locale,
},
});
} catch (error) {
const errorMessage =
error?.message || t('HELP_CENTER.EDIT_ARTICLE_PAGE.API.ERROR');
useAlert(errorMessage);
} finally {
isUpdating.value = false;
}
};
const goBackToArticles = () => {
const { tab, categorySlug, locale } = route.params;
router.push({
name: 'portals_articles_index',
params: { tab, categorySlug, locale },
});
};
</script>
<template>
<ArticleEditor
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="createNewArticle"
@go-back="goBackToArticles"
@set-author="setAuthorId"
@set-category="setCategoryId"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import CategoriesPage from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoriesPage.vue';
const store = useStore();
const route = useRoute();
const categories = useMapGetter('categories/allCategories');
const selectedPortalSlug = computed(() => route.params.portalSlug);
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const isFetching = useMapGetter('categories/isFetching');
const portal = computed(() => getPortalBySlug.value(selectedPortalSlug.value));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale.code,
name: allLocales[locale.code],
code: locale.code,
};
});
});
const fetchCategoriesByPortalSlugAndLocale = async localeCode => {
await store.dispatch('categories/index', {
portalSlug: selectedPortalSlug.value,
locale: localeCode,
});
};
const updateMeta = async localeCode => {
return store.dispatch('portals/show', {
portalSlug: selectedPortalSlug.value,
locale: localeCode,
});
};
const fetchCategories = async localeCode => {
await fetchCategoriesByPortalSlugAndLocale(localeCode);
await updateMeta(localeCode);
};
onMounted(() => {
fetchCategoriesByPortalSlugAndLocale(route.params.locale);
});
</script>
<template>
<CategoriesPage
:categories="categories"
:is-fetching="isFetching"
:allowed-locales="allowedLocales"
@fetch-categories="fetchCategories"
/>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, nextTick, onMounted } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const store = useStore();
const router = useRouter();
const { uiSettings } = useUISettings();
const route = useRoute();
const portals = computed(() => store.getters['portals/allPortals']);
const isPortalPresent = portalSlug => {
return !!portals.value.find(portal => portal.slug === portalSlug);
};
const routeToView = (name, params) => {
router.replace({ name, params, replace: true });
};
const generateRouterParams = () => {
const {
last_active_portal_slug: lastActivePortalSlug,
last_active_locale_code: lastActiveLocaleCode,
} = uiSettings.value || {};
if (isPortalPresent(lastActivePortalSlug)) {
return {
portalSlug: lastActivePortalSlug,
locale: lastActiveLocaleCode,
};
}
if (portals.value.length > 0) {
const { slug: portalSlug, meta: { default_locale: locale } = {} } =
portals.value[0];
return { portalSlug, locale };
}
return null;
};
const routeToLastActivePortal = () => {
const params = generateRouterParams();
const { navigationPath } = route.params;
const isAValidRoute = [
'portals_articles_index',
'portals_categories_index',
'portals_locales_index',
'portals_settings_index',
].includes(navigationPath);
const navigateTo = isAValidRoute ? navigationPath : 'portals_articles_index';
if (params) {
return routeToView(navigateTo, params);
}
return routeToView('portals_new', {});
};
const performRouting = async () => {
await store.dispatch('portals/index');
nextTick(() => routeToLastActivePortal());
};
onMounted(() => performRouting());
</script>
<template>
<div
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
>
<Spinner />
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import allLocales from 'shared/constants/locales.js';
import LocalesPage from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocalesPage.vue';
const route = useRoute();
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const portal = computed(() => getPortalBySlug.value(route.params.portalSlug));
const allowedLocales = computed(() => {
if (!portal.value) {
return [];
}
const { allowed_locales: allAllowedLocales } = portal.value.config;
return allAllowedLocales.map(locale => {
return {
id: locale?.code,
name: allLocales[locale?.code],
code: locale?.code,
articlesCount: locale?.articles_count || 0,
categoriesCount: locale?.categories_count || 0,
};
});
});
</script>
<template>
<LocalesPage :locales="allowedLocales" :portal="portal" />
</template>

View File

@@ -0,0 +1,9 @@
<script setup>
import PortalEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue';
</script>
<template>
<div class="w-full h-full bg-n-background">
<PortalEmptyState />
</div>
</template>

View File

@@ -0,0 +1,164 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import PortalSettings from 'dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalSettings.vue';
const SSL_STATUS_FETCH_INTERVAL = 5000;
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const router = useRouter();
const { isOnChatwootCloud } = useAccount();
const { updateUISettings } = useUISettings();
const portals = useMapGetter('portals/allPortals');
const isFetching = useMapGetter('portals/isFetchingPortals');
const getPortalBySlug = useMapGetter('portals/portalBySlug');
const getNextAvailablePortal = deletedPortalSlug =>
portals.value?.find(portal => portal.slug !== deletedPortalSlug) ?? null;
const getDefaultLocale = slug => {
return getPortalBySlug.value(slug)?.meta?.default_locale;
};
const fetchSSLStatus = () => {
if (!isOnChatwootCloud.value) return;
const { portalSlug } = route.params;
store.dispatch('portals/sslStatus', {
portalSlug,
});
};
const fetchPortalAndItsCategories = async (slug, locale) => {
const selectedPortalParam = { portalSlug: slug, locale };
await Promise.all([
store.dispatch('portals/index'),
store.dispatch('portals/show', selectedPortalParam),
store.dispatch('categories/index', selectedPortalParam),
store.dispatch('agents/get'),
store.dispatch('inboxes/get'),
]);
};
const updateRouteAfterDeletion = async deletedPortalSlug => {
const nextPortal = getNextAvailablePortal(deletedPortalSlug);
if (nextPortal) {
const {
slug,
meta: { default_locale: defaultLocale },
} = nextPortal;
await fetchPortalAndItsCategories(slug, defaultLocale);
router.push({
name: 'portals_articles_index',
params: { portalSlug: slug, locale: defaultLocale },
});
} else {
router.push({ name: 'portals_new' });
}
};
const refreshPortalRoute = async (newSlug, defaultLocale) => {
// This is to refresh the portal route and update the UI settings
// If there is slug change, this will be called to refresh the route and UI settings
await fetchPortalAndItsCategories(newSlug, defaultLocale);
updateUISettings({
last_active_portal_slug: newSlug,
last_active_locale_code: defaultLocale,
});
await router.replace({
name: 'portals_settings_index',
params: { portalSlug: newSlug },
});
};
const updatePortalSettings = async portalObj => {
const { portalSlug } = route.params;
try {
const defaultLocale = getDefaultLocale(portalSlug);
await store.dispatch('portals/update', {
...portalObj,
portalSlug: portalSlug || portalObj?.slug,
});
// If there is a slug change, this will refresh the route and update the UI settings
if (portalObj?.slug && portalSlug !== portalObj.slug) {
await refreshPortalRoute(portalObj.slug, defaultLocale);
}
useAlert(
t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.SUCCESS_MESSAGE')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL_SETTINGS.API.UPDATE_PORTAL.ERROR_MESSAGE')
);
}
};
const deletePortal = async selectedPortalForDelete => {
const { slug } = selectedPortalForDelete;
try {
await store.dispatch('portals/delete', { portalSlug: slug });
await updateRouteAfterDeletion(slug);
useAlert(
t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_SUCCESS')
);
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.PORTAL.PORTAL_SETTINGS.DELETE_PORTAL.API.DELETE_ERROR')
);
}
};
const handleSendCnameInstructions = async payload => {
try {
await store.dispatch('portals/sendCnameInstructions', payload);
useAlert(
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.SUCCESS_MESSAGE'
)
);
} catch (error) {
useAlert(
error?.message ||
t(
'HELP_CENTER.PORTAL.PORTAL_SETTINGS.SEND_CNAME_INSTRUCTIONS.API.ERROR_MESSAGE'
)
);
}
};
const handleUpdatePortal = updatePortalSettings;
const handleUpdatePortalConfiguration = portalObj => {
updatePortalSettings(portalObj);
// If custom domain is added or updated, fetch SSL status after a delay of 5 seconds (only on Chatwoot cloud)
if (portalObj?.custom_domain && isOnChatwootCloud.value) {
setTimeout(() => {
fetchSSLStatus();
}, SSL_STATUS_FETCH_INTERVAL);
}
};
const handleDeletePortal = deletePortal;
</script>
<template>
<PortalSettings
:portals="portals"
:is-fetching="isFetching"
@update-portal="handleUpdatePortal"
@update-portal-configuration="handleUpdatePortalConfiguration"
@delete-portal="handleDeletePortal"
@refresh-status="fetchSSLStatus"
@send-cname-instructions="handleSendCnameInstructions"
/>
</template>

View File

@@ -0,0 +1,43 @@
<script>
import { mapGetters } from 'vuex';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
Spinner,
},
props: {
emptyStateMessage: {
type: String,
default: '',
},
},
computed: {
...mapGetters({
uiFlags: 'notifications/getUIFlags',
}),
emptyMessage() {
if (this.emptyStateMessage) {
return this.emptyStateMessage;
}
return this.$t('INBOX.LIST.NOTE');
},
},
};
</script>
<template>
<div
class="items-center justify-center hidden w-full h-full text-center bg-n-surface-1 lg:flex"
>
<div v-if="uiFlags.isFetching" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<div v-else class="flex flex-col items-center gap-2">
<fluent-icon icon="mail-inbox" size="40" class="text-n-slate-11" />
<span class="text-sm font-medium text-n-slate-11">
{{ emptyMessage }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,276 @@
<script setup>
import { computed, ref, watch, onMounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import wootConstants from 'dashboard/constants/globals';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxCard from 'dashboard/components-next/Inbox/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const { uiSettings } = useUISettings();
const notificationList = ref(null);
const page = ref(1);
const status = ref('');
const type = ref('');
const sortOrder = ref(wootConstants.INBOX_SORT_BY.NEWEST);
const isInboxContextMenuOpen = ref(false);
const infiniteLoaderOptions = computed(() => ({
root: notificationList.value,
rootMargin: '100px 0px 100px 0px',
}));
const meta = useMapGetter('notifications/getMeta');
const uiFlags = useMapGetter('notifications/getUIFlags');
const records = useMapGetter('notifications/getFilteredNotificationsV4');
const inboxById = useMapGetter('inboxes/getInboxById');
const currentConversationId = computed(() => Number(route.params.id));
const inboxFilters = computed(() => ({
page: page.value,
status: status.value,
type: type.value,
sortOrder: sortOrder.value,
}));
const notifications = computed(() => {
return records.value(inboxFilters.value);
});
const showEndOfList = computed(() => {
return uiFlags.value.isAllNotificationsLoaded && !uiFlags.value.isFetching;
});
const showEmptyState = computed(() => {
return !uiFlags.value.isFetching && !notifications.value.length;
});
const stateInbox = inboxId => {
return inboxById.value(inboxId);
};
const fetchNotifications = () => {
page.value = 1;
store.dispatch('notifications/clear');
const filter = inboxFilters.value;
store.dispatch('notifications/index', filter);
};
const scrollActiveIntoView = () => {
const activeEl = notificationList.value?.querySelector('.inbox-card.active');
activeEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
};
const redirectToInbox = () => {
if (route.name === 'inbox_view') return;
router.replace({ name: 'inbox_view' });
};
const loadMoreNotifications = () => {
if (uiFlags.value.isAllNotificationsLoaded) return;
page.value += 1;
store.dispatch('notifications/index', {
page: page.value,
status: status.value,
type: type.value,
sortOrder: sortOrder.value,
});
};
const markNotificationAsRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
const { id, primaryActorId, primaryActorType } = notificationItem;
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: meta.value.unreadCount,
});
useAlert(t('INBOX.ALERTS.MARK_AS_READ'));
store.dispatch('notifications/unReadCount');
} catch {
// error
}
};
const markNotificationAsUnRead = async notificationItem => {
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
redirectToInbox();
const { id } = notificationItem;
try {
await store.dispatch('notifications/unread', { id });
useAlert(t('INBOX.ALERTS.MARK_AS_UNREAD'));
store.dispatch('notifications/unReadCount');
} catch {
// error
}
};
const deleteNotification = async notificationItem => {
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
redirectToInbox();
try {
await store.dispatch('notifications/delete', {
notification: notificationItem,
unread_count: meta.value.unreadCount,
count: meta.value.count,
});
useAlert(t('INBOX.ALERTS.DELETE'));
} catch {
// error
}
};
const onFilterChange = option => {
const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
if (option.type === STATUS) {
status.value = option.selected ? option.key : '';
}
if (option.type === TYPE) {
type.value = option.selected ? option.key : '';
}
if (option.type === SORT_ORDER) {
sortOrder.value = option.key;
}
fetchNotifications();
};
const setSavedFilter = () => {
const { inbox_filter_by: filterBy = {} } = uiSettings.value;
const { status: savedStatus, type: savedType, sort_by: sortBy } = filterBy;
status.value = savedStatus;
type.value = savedType;
sortOrder.value = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
store.dispatch('notifications/setNotificationFilters', inboxFilters.value);
};
const openConversation = async notificationItem => {
const {
id,
primaryActorId,
primaryActorType,
primaryActor: { inboxId, id: conversationId },
notificationType,
} = notificationItem;
if (route.params.id === String(conversationId)) return;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount: meta.value.unreadCount,
});
// to update the unread count in the store realtime
store.dispatch('notifications/unReadCount');
router.push({
name: 'inbox_view_conversation',
params: { inboxId, type: 'conversation', id: conversationId },
});
} catch {
// error
}
};
watch(
inboxFilters,
(newVal, oldVal) => {
if (newVal !== oldVal) {
store.dispatch('notifications/updateNotificationFilters', newVal);
}
},
{ deep: true }
);
watch(currentConversationId, () => {
nextTick(scrollActiveIntoView);
});
onMounted(() => {
scrollActiveIntoView();
setSavedFilter();
fetchNotifications();
});
</script>
<template>
<section class="flex w-full h-full bg-n-surface-1">
<div
class="flex flex-col h-full w-full lg:min-w-[340px] lg:max-w-[340px] ltr:border-r rtl:border-l border-n-weak"
:class="!currentConversationId ? 'flex' : 'hidden xl:flex'"
>
<InboxListHeader
:is-context-menu-open="isInboxContextMenuOpen"
@filter="onFilterChange"
@redirect="redirectToInbox"
/>
<div
ref="notificationList"
class="flex flex-col gap-0.5 w-full h-[calc(100%-56px)] pb-4 overflow-x-hidden px-2 overflow-y-auto divide-y divide-n-weak [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
>
<InboxCard
v-for="notificationItem in notifications"
:key="notificationItem.id"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentConversationId === notificationItem.primaryActor?.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 !rounded-lg active'
: ''
"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-un-read="markNotificationAsUnRead"
@delete-notification="deleteNotification"
@context-menu-open="isInboxContextMenuOpen = true"
@context-menu-close="isInboxContextMenuOpen = false"
@click="openConversation(notificationItem)"
/>
<div v-if="uiFlags.isFetching" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-if="showEmptyState"
class="p-4 text-sm font-medium text-center text-n-slate-10"
>
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
</p>
<IntersectionObserver
v-if="!showEndOfList && !uiFlags.isFetching"
:options="infiniteLoaderOptions"
@observed="loadMoreNotifications"
/>
</div>
</div>
<router-view />
<CmdBarConversationSnooze />
</section>
</template>

View File

@@ -0,0 +1,224 @@
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import InboxItemHeader from './components/InboxItemHeader.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
const route = useRoute();
const router = useRouter();
const store = useStore();
const { uiSettings } = useUISettings();
const isConversationLoading = ref(false);
const notification = useMapGetter('notifications/getFilteredNotifications');
const currentChat = useMapGetter('getSelectedChat');
const conversationById = useMapGetter('getConversationById');
const uiFlags = useMapGetter('notifications/getUIFlags');
const meta = useMapGetter('notifications/getMeta');
const inboxId = computed(() => Number(route.params.inboxId));
const conversationId = computed(() => Number(route.params.id));
const activeSortOrder = computed(() => {
const { inbox_filter_by: filterBy = {} } = uiSettings.value;
const { sort_by: sortBy } = filterBy;
return sortBy || 'desc';
});
const notifications = computed(() => {
return notification.value({
sortOrder: activeSortOrder.value,
});
});
const activeNotification = computed(() => {
return notifications.value?.find(
n => n.primary_actor?.id === conversationId.value
);
});
const totalNotificationCount = computed(() => {
return meta.value.count;
});
const showEmptyState = computed(() => {
return (
!conversationId.value ||
(!notifications.value?.length && uiFlags.value.isFetching)
);
});
const activeNotificationIndex = computed(() => {
return notifications.value?.findIndex(
n => n.primary_actor?.id === conversationId.value
);
});
const isContactPanelOpen = computed(() => {
if (currentChat.value.id) {
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
return isContactSidebarOpen;
}
return false;
});
const findConversation = () => {
return conversationById.value(conversationId.value);
};
const openNotification = async notificationItem => {
const {
id,
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: {
meta: { unreadCount } = {},
id: conversationIdFromNotification,
},
notification_type: notificationType,
} = notificationItem;
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
notificationType,
});
try {
await store.dispatch('notifications/read', {
id,
primaryActorId,
primaryActorType,
unreadCount,
});
router.push({
name: 'inbox_view_conversation',
params: { type: 'conversation', id: conversationIdFromNotification },
});
} catch {
// error
}
};
const setActiveChat = async () => {
const selectedConversation = findConversation();
if (!selectedConversation) return;
try {
await store.dispatch('setActiveChat', { data: selectedConversation });
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
} catch {
// error
}
};
const fetchConversationById = async () => {
if (!conversationId.value) return;
store.dispatch('clearSelectedState');
const existingChat = findConversation();
if (existingChat) {
await setActiveChat();
return;
}
isConversationLoading.value = true;
try {
await store.dispatch('getConversation', conversationId.value);
await setActiveChat();
} catch {
// error
} finally {
isConversationLoading.value = false;
}
};
const navigateToConversation = (activeIndex, direction) => {
const isValidPrev = direction === 'prev' && activeIndex > 0;
const isValidNext =
direction === 'next' && activeIndex < totalNotificationCount.value - 1;
if (!isValidPrev && !isValidNext) return;
const updatedIndex = direction === 'prev' ? activeIndex - 1 : activeIndex + 1;
const targetNotification = notifications.value[updatedIndex];
if (targetNotification) {
openNotification(targetNotification);
}
};
const onClickNext = () => {
navigateToConversation(activeNotificationIndex.value, 'next');
};
const onClickPrev = () => {
navigateToConversation(activeNotificationIndex.value, 'prev');
};
watch(
conversationId,
(newVal, oldVal) => {
if (newVal !== oldVal) {
fetchConversationById();
}
},
{ immediate: true }
);
onMounted(async () => {
await store.dispatch('agents/get');
});
</script>
<template>
<div class="h-full w-full flex-1">
<div v-if="showEmptyState" class="flex w-full h-full">
<InboxEmptyState
:empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
/>
</div>
<div v-else class="flex flex-col w-full h-full">
<InboxItemHeader
:total-length="totalNotificationCount"
:current-index="activeNotificationIndex"
:active-notification="activeNotification"
@next="onClickNext"
@prev="onClickPrev"
/>
<div
v-if="isConversationLoading"
class="flex items-center flex-1 my-4 justify-center bg-n-solid-1"
>
<Spinner class="text-n-brand" />
</div>
<div v-else class="flex h-[calc(100%-48px)] min-w-0">
<ConversationBox
class="flex-1 [&.conversation-details-wrap]:!border-0"
is-inbox-view
:inbox-id="inboxId"
:is-on-expanded-layout="false"
>
<SidepanelSwitch v-if="currentChat.id" />
</ConversationBox>
<ConversationSidebar
v-if="isContactPanelOpen"
:current-chat="currentChat"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup>
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
defineProps({
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'selectAction']);
const handleClose = () => {
emit('close');
};
const onMenuItemClick = key => {
emit('selectAction', key);
handleClose();
};
</script>
<template>
<ContextMenu
:x="contextMenuPosition.x"
:y="contextMenuPosition.y"
@close="handleClose"
>
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<MenuItem
v-for="item in menuItems"
:key="item.key"
:option="item"
variant="icon"
class="!w-48"
@click.stop="onMenuItemClick(item.key)"
/>
</div>
</ContextMenu>
</template>

View File

@@ -0,0 +1,197 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { useUISettings } from 'dashboard/composables/useUISettings';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['filter'],
setup() {
const { uiSettings, updateUISettings } = useUISettings();
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
showSortMenu: false,
displayOptions: [
{
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.SNOOZED'),
key: wootConstants.INBOX_DISPLAY_BY.SNOOZED,
selected: false,
type: wootConstants.INBOX_FILTER_TYPE.STATUS,
},
{
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.READ'),
key: wootConstants.INBOX_DISPLAY_BY.READ,
selected: false,
type: wootConstants.INBOX_FILTER_TYPE.TYPE,
},
],
sortOptions: [
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.NEWEST'),
key: wootConstants.INBOX_SORT_BY.NEWEST,
type: wootConstants.INBOX_FILTER_TYPE.SORT_ORDER,
},
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.OLDEST'),
key: wootConstants.INBOX_SORT_BY.OLDEST,
type: wootConstants.INBOX_FILTER_TYPE.SORT_ORDER,
},
],
activeSort: wootConstants.INBOX_SORT_BY.NEWEST,
activeDisplayFilter: {
status: '',
type: '',
},
};
},
computed: {
activeSortOption() {
return (
this.sortOptions.find(option => option.key === this.activeSort)?.name ||
''
);
},
},
mounted() {
this.setSavedFilter();
},
methods: {
updateDisplayOption(option) {
this.displayOptions.forEach(displayOption => {
if (displayOption.key === option.key) {
displayOption.selected = !option.selected;
this.activeDisplayFilter[displayOption.type] = displayOption.selected
? displayOption.key
: '';
this.saveSelectedDisplayFilter();
this.$emit('filter', option);
}
});
},
openSortMenu() {
this.showSortMenu = !this.showSortMenu;
},
onSortOptionClick(option) {
this.activeSort = option.key;
this.showSortMenu = false;
this.saveSelectedDisplayFilter();
this.$emit('filter', option);
},
saveSelectedDisplayFilter() {
this.updateUISettings({
inbox_filter_by: {
...this.activeDisplayFilter,
sort_by: this.activeSort || wootConstants.INBOX_SORT_BY.NEWEST,
},
});
},
setSavedFilter() {
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
const { status, type, sort_by: sortBy } = filterBy;
this.activeSort = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.displayOptions.forEach(option => {
option.selected =
option.type === wootConstants.INBOX_FILTER_TYPE.STATUS
? option.key === status
: option.key === type;
this.activeDisplayFilter[option.type] = option.selected
? option.key
: '';
});
},
},
};
</script>
<template>
<div
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container shadow-lg z-50 max-w-64 min-w-[170px] w-fit rounded-xl divide-y divide-n-weak dark:divide-n-strong"
>
<div class="flex items-center gap-2 justify-between p-3 rounded-t-lg h-11">
<div class="flex gap-1.5 min-w-0">
<span class="i-lucide-arrow-down-up size-3.5 text-n-slate-12" />
<span class="text-xs font-medium text-n-slate-12 truncate min-w-0">
{{ $t('INBOX.DISPLAY_MENU.SORT') }}
</span>
</div>
<div v-on-clickaway="() => (showSortMenu = false)" class="relative">
<NextButton
:label="activeSortOption"
icon="i-lucide-chevron-down"
slate
trailing-icon
xs
outline
class="w-fit min-w-20 max-w-32"
@click="openSortMenu"
/>
<div
v-if="showSortMenu"
class="absolute flex flex-col gap-0.5 bg-n-alpha-3 backdrop-blur-[100px] z-60 rounded-lg p-0.5 w-fit min-w-20 max-w-32 top-px outline outline-1 outline-n-container dark:outline-n-strong"
>
<div
v-for="option in sortOptions"
:key="option.key"
role="button"
class="flex rounded-md h-5 w-full items-center justify-between px-1.5 py-0.5 gap-2 whitespace-nowrap"
:class="{
'bg-n-brand/10 dark:bg-n-brand/10': activeSort === option.key,
}"
@click.stop="onSortOptionClick(option)"
>
<span
class="text-xs font-medium hover:text-n-brand truncate min-w-0 dark:hover:text-n-brand"
:class="{
'text-n-blue-11 dark:text-n-blue-11': activeSort === option.key,
'text-n-slate-11': activeSort !== option.key,
}"
>
{{ option.name }}
</span>
<span
v-if="activeSort === option.key"
class="i-lucide-check size-2.5 flex-shrink-0 text-n-blue-11"
/>
</div>
</div>
</div>
</div>
<div>
<span class="px-3 py-4 text-xs font-medium text-n-slate-11">
{{ $t('INBOX.DISPLAY_MENU.DISPLAY') }}
</span>
<div class="flex flex-col divide-y divide-n-weak dark:divide-n-strong">
<div
v-for="option in displayOptions"
:key="option.key"
class="flex items-center px-3 py-2 gap-1.5 h-9"
>
<input
:id="option.key"
type="checkbox"
:name="option.key"
:checked="option.selected"
class="m-0 border-[1.5px] shadow border-n-weak appearance-none rounded-[4px] w-4 h-4 dark:bg-n-background focus:ring-1 focus:ring-n-weak dark:focus:ring-n-strong checked:bg-n-brand dark:checked:bg-n-brand after:content-[''] after:text-white checked:after:content-['✓'] after:flex after:items-center after:justify-center checked:border-t checked:border-n-blue-10 checked:border-b-0 checked:border-r-0 checked:border-l-0 after:text-center after:text-xs after:font-bold after:relative after:-top-[1.5px]"
@change="updateDisplayOption(option)"
/>
<label
:for="option.key"
class="text-xs font-medium text-n-slate-12 !ml-0 !mr-0 dark:text-n-slate-12"
>
{{ option.name }}
</label>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,158 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert, useTrack } from 'dashboard/composables';
import { getUnixTime } from 'date-fns';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
import wootConstants from 'dashboard/constants/globals';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import PaginationButton from './PaginationButton.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import { emitter } from 'shared/helpers/mitt';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
PaginationButton,
NextButton,
BackButton,
CustomSnoozeModal,
},
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
activeNotification: {
type: Object,
default: null,
},
},
emits: ['next', 'prev'],
data() {
return { showCustomSnoozeModal: false };
},
computed: {
...mapGetters({ meta: 'notifications/getMeta' }),
},
mounted() {
emitter.on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
unmounted() {
emitter.off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
openSnoozeNotificationModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_notification' });
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
async snoozeNotification(snoozedUntil) {
try {
await this.$store.dispatch('notifications/snooze', {
id: this.activeNotification?.id,
snoozedUntil,
});
useAlert(this.$t('INBOX.ALERTS.SNOOZE'));
} catch (error) {
// Silently fail without any change in the UI
}
},
onCmdSnoozeNotification(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
const snoozedUntil = findSnoozeTime(snoozeType) || null;
this.snoozeNotification(snoozedUntil);
}
},
scheduleCustomSnooze(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
const snoozedUntil = getUnixTime(customSnoozeTime) || null;
this.snoozeNotification(snoozedUntil);
}
},
deleteNotification() {
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
this.$store
.dispatch('notifications/delete', {
notification: this.activeNotification,
unread_count: this.meta.unreadCount,
count: this.meta.count,
})
.then(() => {
useAlert(this.$t('INBOX.ALERTS.DELETE'));
});
this.$router.replace({ name: 'inbox_view' });
},
onClickNext() {
this.$emit('next');
},
onClickPrev() {
this.$emit('prev');
},
onClickGoToInboxList() {
this.$router.replace({ name: 'inbox_view' });
},
},
};
</script>
<template>
<div
class="flex items-center justify-between w-full gap-2 border-b px-3 h-12 rtl:border-r border-n-weak flex-shrink-0 bg-n-surface-1"
>
<div class="flex items-center gap-4">
<BackButton
compact
:button-label="$t('INBOX.ACTION_HEADER.BACK')"
class="xl:hidden flex"
/>
<PaginationButton
v-if="totalLength > 1"
:total-length="totalLength"
:current-index="currentIndex + 1"
@next="onClickNext"
@prev="onClickPrev"
/>
</div>
<div class="flex items-center gap-2">
<NextButton
:label="$t('INBOX.ACTION_HEADER.SNOOZE')"
icon="i-lucide-bell-minus"
slate
xs
faded
class="[&>.truncate]:hidden md:[&>.truncate]:block"
@click="openSnoozeNotificationModal"
/>
<NextButton
:label="$t('INBOX.ACTION_HEADER.DELETE')"
icon="i-lucide-trash-2"
slate
xs
faded
class="[&>.truncate]:hidden md:[&>.truncate]:block"
@click="deleteNotification"
/>
</div>
<woot-modal
v-model:show="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="scheduleCustomSnooze"
/>
</woot-modal>
</div>
</template>

View File

@@ -0,0 +1,123 @@
<script>
import { useAlert, useTrack } from 'dashboard/composables';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import InboxOptionMenu from './InboxOptionMenu.vue';
import InboxDisplayMenu from './InboxDisplayMenu.vue';
export default {
components: {
NextButton,
InboxOptionMenu,
InboxDisplayMenu,
},
props: {
isContextMenuOpen: {
type: Boolean,
default: false,
},
},
emits: ['redirect', 'filter'],
data() {
return {
showInboxDisplayMenu: false,
showInboxOptionMenu: false,
};
},
watch: {
isContextMenuOpen: {
handler(val) {
if (val) {
this.showInboxDisplayMenu = false;
this.showInboxOptionMenu = false;
}
},
immediate: true,
},
},
methods: {
markAllRead() {
useTrack(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll').then(() => {
useAlert(this.$t('INBOX.ALERTS.MARK_ALL_READ'));
});
},
deleteAll() {
this.$store.dispatch('notifications/deleteAll').then(() => {
useAlert(this.$t('INBOX.ALERTS.DELETE_ALL'));
});
},
deleteAllRead() {
this.$store.dispatch('notifications/deleteAllRead').then(() => {
useAlert(this.$t('INBOX.ALERTS.DELETE_ALL_READ'));
});
},
openInboxDisplayMenu() {
this.showInboxDisplayMenu = !this.showInboxDisplayMenu;
},
openInboxOptionsMenu() {
this.showInboxOptionMenu = !this.showInboxOptionMenu;
},
onInboxOptionMenuClick(key) {
const actions = {
mark_all_read: () => this.markAllRead(),
delete_all: () => this.deleteAll(),
delete_all_read: () => this.deleteAllRead(),
};
const action = actions[key];
if (action) action();
this.$emit('redirect');
},
onFilterChange(option) {
this.$emit('filter', option);
this.showInboxDisplayMenu = false;
this.$emit('redirect');
},
},
};
</script>
<template>
<div
class="flex items-center justify-between w-full gap-1 h-[3.25rem] ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h1 class="text-heading-2 truncate text-n-slate-12 min-w-0">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
<NextButton
:label="$t('INBOX.LIST.DISPLAY_DROPDOWN')"
icon="i-lucide-chevron-down"
trailing-icon
slate
xs
:variant="showInboxDisplayMenu ? 'faded' : 'solid'"
@click="openInboxDisplayMenu"
/>
<InboxDisplayMenu
v-if="showInboxDisplayMenu"
v-on-clickaway="openInboxDisplayMenu"
class="absolute mt-1 top-full ltr:left-0 rtl:right-0"
@filter="onFilterChange"
/>
</div>
</div>
<div class="relative flex items-center gap-1">
<NextButton
icon="i-lucide-sliders-vertical"
slate
sm
:variant="showInboxOptionMenu ? 'faded' : 'ghost'"
@click="openInboxOptionsMenu"
/>
<InboxOptionMenu
v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu"
class="absolute top-full mt-1 ltr:right-0 ltr:lg:right-[unset] rtl:left-0 rtl:lg:left-[unset]"
@option-click="onInboxOptionMenuClick"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script>
import MenuItem from './MenuItem.vue';
export default {
components: {
MenuItem,
},
emits: ['optionClick'],
data() {
return {
menuItems: [
{
key: 'mark_all_read',
label: this.$t('INBOX.MENU_ITEM.MARK_ALL_READ'),
},
{
key: 'delete_all',
label: this.$t('INBOX.MENU_ITEM.DELETE_ALL'),
},
{
key: 'delete_all_read',
label: this.$t('INBOX.MENU_ITEM.DELETE_ALL_READ'),
},
],
};
},
methods: {
onClick(key) {
this.$emit('optionClick', key);
},
},
};
</script>
<template>
<div
class="z-50 flex flex-col max-w-64 min-w-40 gap-1 bg-n-alpha-3 backdrop-blur-[100px] divide-y py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl divide-n-weak dark:divide-n-strong"
>
<div class="flex flex-col">
<MenuItem
v-for="item in menuItems"
:key="item.key"
:label="item.label"
@click.stop="onClick(item.key)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
defineProps({
label: {
type: String,
default: '',
},
});
</script>
<template>
<div
role="button"
class="flex items-center w-full h-8 px-2 py-1 rounded-md cursor-pointer hover:text-n-blue-11 min-w-0"
>
<span class="text-xs font-medium truncate text-n-slate-12">
{{ label }}
</span>
</div>
</template>

View File

@@ -0,0 +1,77 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
props: {
totalLength: {
type: Number,
default: 0,
},
currentIndex: {
type: Number,
default: 0,
},
},
emits: ['prev', 'next'],
computed: {
isUpDisabled() {
return this.currentIndex === 1;
},
isDownDisabled() {
return this.currentIndex === this.totalLength || this.totalLength <= 1;
},
},
methods: {
handleUpClick() {
if (this.currentIndex > 1) {
this.$emit('prev');
}
},
handleDownClick() {
if (this.currentIndex < this.totalLength) {
this.$emit('next');
}
},
},
};
</script>
<template>
<div class="flex gap-2 items-center">
<div class="flex gap-1 items-center">
<NextButton
icon="i-lucide-chevron-up"
xs
slate
faded
:disabled="isUpDisabled"
@click="handleUpClick"
/>
<NextButton
icon="i-lucide-chevron-down"
xs
slate
faded
:disabled="isDownDisabled"
@click="handleDownClick"
/>
</div>
<div class="flex items-center gap-1 whitespace-nowrap">
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
{{ totalLength <= 1 ? '1' : currentIndex }}
</span>
<span
v-if="totalLength > 1"
class="text-sm text-n-slate-9 relative -top-px"
>
/
</span>
<span v-if="totalLength > 1" class="text-sm text-n-slate-9 tabular-nums">
{{ totalLength }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,16 @@
export const NOTIFICATION_TYPES_MAPPING = {
CONVERSATION_MENTION: ['i-lucide-at-sign', 'text-n-blue-11'],
CONVERSATION_ASSIGNMENT: ['i-lucide-chevrons-right', 'text-n-blue-11'],
CONVERSATION_CREATION: ['i-lucide-mail-plus', 'text-n-blue-11'],
PARTICIPATING_CONVERSATION_NEW_MESSAGE: [
'i-lucide-message-square-plus',
'text-n-blue-11',
],
ASSIGNED_CONVERSATION_NEW_MESSAGE: [
'i-lucide-message-square-plus',
'text-n-blue-11',
],
SLA_MISSED_FIRST_RESPONSE: ['i-lucide-heart-crack', 'text-n-ruby-11'],
SLA_MISSED_NEXT_RESPONSE: ['i-lucide-heart-crack', 'text-n-ruby-11'],
SLA_MISSED_RESOLUTION: ['i-lucide-heart-crack', 'text-n-ruby-11'],
};

View File

@@ -0,0 +1,33 @@
import { frontendURL } from 'dashboard/helper/URLHelper';
import InboxListView from './InboxList.vue';
import InboxDetailView from './InboxView.vue';
import InboxEmptyStateView from './InboxEmptyState.vue';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
export const routes = [
{
path: frontendURL('accounts/:accountId/inbox-view'),
component: InboxListView,
children: [
{
path: '',
name: 'inbox_view',
component: InboxEmptyStateView,
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
{
path: ':type/:id',
name: 'inbox_view_conversation',
component: InboxDetailView,
meta: {
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
},
},
],
},
];

View File

@@ -0,0 +1,39 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Auth from 'dashboard/api/auth';
const { t } = useI18n();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const message = computed(() => {
if (isOnChatwootCloud.value) {
return t('APP_GLOBAL.NO_ACCOUNTS.MESSAGE_CLOUD');
}
return t('APP_GLOBAL.NO_ACCOUNTS.MESSAGE_SELF_HOSTED');
});
const handleLogout = () => {
Auth.logout();
};
</script>
<template>
<div
class="flex flex-col flex-1 items-center justify-center w-full h-full gap-6 bg-n-slate-2"
>
<EmptyState
:title="$t('APP_GLOBAL.NO_ACCOUNTS.TITLE')"
:message="message"
/>
<NextButton
variant="smooth"
color-scheme="secondary"
:label="$t('APP_GLOBAL.NO_ACCOUNTS.LOGOUT')"
@click="handleLogout"
/>
</div>
</template>

View File

@@ -0,0 +1,211 @@
<script>
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
EmptyState,
NextButton,
},
props: {
notifications: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
isUpdating: {
type: Boolean,
default: false,
},
onClickNotification: {
type: Function,
default: () => {},
},
onMarkAllDoneClick: {
type: Function,
default: () => {},
},
},
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
}),
showEmptyResult() {
return !this.isLoading && this.notifications.length === 0;
},
},
methods: {
dynamicTime,
},
};
</script>
<template>
<section
class="flex-grow flex-shrink h-full px-4 py-8 overflow-hidden bg-n-background"
>
<div class="flex w-full items-center justify-between gap-2 mb-4">
<h6 class="text-xl font-medium text-n-slate-12">
{{ $t('NOTIFICATIONS_PAGE.HEADER') }}
</h6>
<NextButton
v-if="notificationMetadata.unreadCount"
type="submit"
sm
:label="$t('NOTIFICATIONS_PAGE.MARK_ALL_DONE')"
:is-loading="isUpdating"
@click="onMarkAllDoneClick"
/>
</div>
<table class="notifications-table overflow-auto">
<tbody v-show="!isLoading">
<tr
v-for="notificationItem in notifications"
:key="notificationItem.id"
:class="{
'is-unread': notificationItem.read_at === null,
}"
class="border-b border-n-weak"
@click="() => onClickNotification(notificationItem)"
>
<td class="p-2.5 text-n-slate-12">
<div
class="overflow-hidden flex-view notification-contant--wrap whitespace-nowrap text-ellipsis"
>
<h5 class="notification--title">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</h5>
<span
class="overflow-hidden notification--message-title whitespace-nowrap text-ellipsis"
>
{{ notificationItem.push_message_title }}
</span>
</div>
</td>
<td class="text-right">
<span class="notification--type">
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</td>
<td class="thumbnail--column">
<Avatar
v-if="notificationItem.primary_actor.meta.assignee"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
:size="28"
:name="notificationItem.primary_actor.meta.assignee.name"
rounded-full
/>
</td>
<td>
<div class="text-right timestamp--column ltr:mr-2 rtl:ml-2">
<span class="notification--created-at">
{{ dynamicTime(notificationItem.last_activity_at) }}
</span>
</div>
</td>
<td>
<div
v-if="!notificationItem.read_at"
class="notification--unread-indicator"
/>
</td>
</tr>
</tbody>
</table>
<EmptyState
v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.LIST.404')"
/>
<div v-if="isLoading" class="notifications--loader">
<Spinner />
<span>{{ $t('NOTIFICATIONS_PAGE.LIST.LOADING_MESSAGE') }}</span>
</div>
</section>
</template>
<style lang="scss" scoped>
.notification--title {
@apply text-sm m-0 text-n-slate-12;
}
.notifications-table {
> tbody {
> tr {
@apply cursor-pointer;
&:hover {
@apply bg-n-slate-3;
}
&.is-active {
@apply bg-n-slate-4 dark:bg-n-slate-6;
}
> td {
&.conversation-count-item {
@apply pl-6 rtl:pl-0 rtl:pr-6;
}
}
&:last-child {
@apply border-b-0;
}
}
}
}
.is-unread {
@apply font-semibold;
}
.notifications--loader {
@apply text-base flex items-center justify-center p-10;
}
.notification--unread-indicator {
@apply w-2.5 h-2.5 rounded-full bg-n-brand;
}
.notification--created-at {
@apply text-n-slate-11 text-xs;
}
.notification--type {
@apply text-xs;
}
.thumbnail--column {
@apply w-[3.25rem];
}
.timestamp--column {
@apply min-w-[9.125rem] text-right;
}
.notification-contant--wrap {
@apply flex-col max-w-[31.25rem];
}
.notification--message-title {
@apply text-n-slate-12;
}
</style>

View File

@@ -0,0 +1,79 @@
<script>
import { mapGetters } from 'vuex';
import { useTrack } from 'dashboard/composables';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import NotificationTable from './NotificationTable.vue';
import { ACCOUNT_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
components: {
NotificationTable,
TableFooter,
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
},
mounted() {
this.$store.dispatch('notifications/get', { page: 1 });
},
methods: {
onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
this.$store.dispatch('notifications/get', { page });
},
openConversation(notification) {
const {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId },
notification_type: notificationType,
} = notification;
useTrack(ACCOUNT_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, {
notificationType,
});
this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push(
`/app/accounts/${this.accountId}/conversations/${conversationId}`
);
},
onMarkAllDoneClick() {
useTrack(ACCOUNT_EVENTS.MARK_AS_READ_NOTIFICATIONS);
this.$store.dispatch('notifications/readAll');
},
},
};
</script>
<template>
<div class="h-full overflow-y-auto">
<div class="flex flex-col h-full">
<NotificationTable
:notifications="records"
:is-loading="uiFlags.isFetching"
:is-updating="uiFlags.isUpdating"
:on-click-notification="openConversation"
:on-mark-all-done-click="onMarkAllDoneClick"
/>
<TableFooter
class="border-t border-n-weak"
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
:page-size="15"
@page-change="onPageChange"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/SettingsWrapper.vue';
import NotificationsView from './components/NotificationsView.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/notifications'),
component: SettingsWrapper,
children: [
{
path: '',
name: 'notifications_index',
component: NotificationsView,
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
},
],
},
];

View File

@@ -0,0 +1,60 @@
<script>
import { useAdmin } from 'dashboard/composables/useAdmin';
import BackButton from '../../../components/widgets/BackButton.vue';
export default {
components: {
BackButton,
},
props: {
headerTitle: {
default: '',
type: String,
},
icon: {
default: '',
type: String,
},
showBackButton: { type: Boolean, default: false },
backUrl: {
type: [String, Object],
default: '',
},
backButtonLabel: {
type: String,
default: '',
},
},
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
computed: {
iconClass() {
return `icon ${this.icon} header--icon`;
},
},
};
</script>
<template>
<div
class="flex justify-between items-center h-20 min-h-[3.5rem] px-6 py-2 bg-n-surface-1"
>
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
<BackButton
v-if="showBackButton"
:button-label="backButtonLabel"
:back-url="backUrl"
class="ltr:mr-4 rtl:ml-4"
/>
<slot />
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
</h1>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup>
defineProps({
isLoading: {
type: Boolean,
default: false,
},
noRecordsFound: {
type: Boolean,
default: false,
},
loadingMessage: {
type: String,
default: '',
},
noRecordsMessage: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col w-full h-full gap-4 font-inter">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<main>
<slot name="preBody" />
<slot v-if="isLoading" name="loading">
<woot-loading-state :message="loadingMessage" />
</slot>
<p
v-else-if="noRecordsFound"
class="flex-1 py-20 text-n-slate-12 flex items-center justify-center text-base"
>
{{ noRecordsMessage }}
</p>
<slot v-else name="body" />
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,20 @@
<script>
export default {
props: {
headerTitle: { type: String, default: '' },
headerContent: { type: String, default: '' },
},
};
</script>
<template>
<div class="flex flex-col gap-1.5 w-full items-start mb-4">
<h2 class="text-heading-1 text-n-slate-12 break-words">
{{ headerTitle }}
</h2>
<p
v-dompurify-html="headerContent"
class="text-body-main w-full text-n-slate-11"
/>
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
import { useRoute } from 'vue-router';
defineProps({
keepAlive: {
type: Boolean,
default: true,
},
});
const route = useRoute();
</script>
<template>
<div
class="flex flex-col w-full h-full m-0 pb-8 pt-4 px-6 overflow-auto bg-n-surface-1"
>
<div class="flex items-start w-full max-w-5xl mx-auto">
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-else :key="route.fullPath" />
</router-view>
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingsHeader from './SettingsHeader.vue';
const props = defineProps({
headerTitle: { type: String, default: '' },
icon: { type: String, default: '' },
keepAlive: { type: Boolean, default: true },
showBackButton: { type: Boolean, default: false },
backUrl: { type: [String, Object], default: '' },
});
const { t } = useI18n();
const showSettingsHeader = computed(
() => props.headerTitle || props.icon || props.showBackButton
);
</script>
<template>
<div class="flex flex-col h-full m-0 bg-n-surface-1 w-full">
<SettingsHeader
v-if="showSettingsHeader"
:icon="icon"
:header-title="t(headerTitle)"
:show-back-button="showBackButton"
:back-url="backUrl"
class="z-20 max-w-7xl w-full mx-auto"
/>
<router-view v-slot="{ Component }" class="px-4 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</div>
</template>

View File

@@ -0,0 +1,242 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
components: {
BaseSettingsHeader,
NextButton,
AccountId,
BuildInfo,
AccountDelete,
AudioTranscription,
SectionLayout,
WithLabel,
NextInput,
},
setup() {
const { updateUISettings, uiSettings } = useUISettings();
const { enabledLanguages } = useConfig();
const { accountId } = useAccount();
const v$ = useVuelidate();
return { updateUISettings, uiSettings, v$, enabledLanguages, accountId };
},
data() {
return {
id: '',
name: '',
locale: 'en',
domain: '',
supportEmail: '',
features: {},
};
},
validations: {
name: {
required,
},
locale: {
required,
},
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
showAudioTranscriptionConfig() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.CAPTAIN
);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
l1.iso_639_1_code.localeCompare(l2.iso_639_1_code)
);
},
isUpdating() {
return this.uiFlags.isUpdating;
},
featureInboundEmailEnabled() {
return !!this.features?.inbound_emails;
},
featureCustomReplyDomainEnabled() {
return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
);
},
featureCustomReplyEmailEnabled() {
return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_email
);
},
currentAccount() {
return this.getAccount(this.accountId) || {};
},
},
mounted() {
this.initializeAccount();
},
methods: {
async initializeAccount() {
try {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
this.name = name;
this.locale = locale;
this.id = id;
this.domain = domain;
this.supportEmail = support_email;
this.features = features;
} catch (error) {
// Ignore error
}
},
async updateAccount() {
this.v$.$touch();
if (this.v$.$invalid) {
useAlert(this.$t('GENERAL_SETTINGS.FORM.ERROR'));
return;
}
try {
await this.$store.dispatch('accounts/update', {
locale: this.locale,
name: this.name,
domain: this.domain,
support_email: this.supportEmail,
});
// If user locale is set, update the locale with user locale
if (this.uiSettings?.locale) {
this.$root.$i18n.locale = this.uiSettings?.locale;
} else {
// If user locale is not set, update the locale with account locale
this.$root.$i18n.locale = this.locale;
}
this.getAccount(this.id).locale = this.locale;
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
},
};
</script>
<template>
<div class="flex flex-col w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<div class="flex-grow flex-shrink min-w-0 mt-3">
<SectionLayout
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
class="!pt-0"
>
<form
v-if="!uiFlags.isFetchingItem"
class="grid gap-4"
@submit.prevent="updateAccount"
>
<WithLabel
:has-error="v$.name.$error"
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
>
<NextInput
v-model="name"
type="text"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
</WithLabel>
<WithLabel
:has-error="v$.locale.$error"
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
>
<select v-model="locale" class="!mb-0 text-sm">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
</WithLabel>
<WithLabel
v-if="featureCustomReplyDomainEnabled"
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
>
<NextInput
v-model="domain"
type="text"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
<template #help>
{{
featureInboundEmailEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED')
}}
{{
featureCustomReplyDomainEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</template>
</WithLabel>
<WithLabel
v-if="featureCustomReplyEmailEnabled"
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
>
<NextInput
v-model="supportEmail"
type="text"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</WithLabel>
<div>
<NextButton blue :is-loading="isUpdating" type="submit">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</NextButton>
</div>
</form>
</SectionLayout>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />
</div>
<BuildInfo />
</div>
</template>

View File

@@ -0,0 +1,25 @@
import { frontendURL } from '../../../../helper/URLHelper';
import Index from './Index.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/general'),
meta: {
permissions: ['administrator'],
},
component: SettingsWrapper,
children: [
{
path: '',
name: 'general_settings_index',
component: Index,
meta: {
permissions: ['administrator'],
},
},
],
},
],
};

View File

@@ -0,0 +1,147 @@
<script setup>
import { computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('accounts/getUIFlags');
const { currentAccount } = useAccount();
const [showDeletePopup, toggleDeletePopup] = useToggle();
const confirmPlaceHolderText = computed(() => {
return `${t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER', {
accountName: currentAccount.value.name,
})}`;
});
const isMarkedForDeletion = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return !!custom_attributes.marked_for_deletion_at;
});
const markedForDeletionDate = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
});
const markedForDeletionReason = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
});
const formattedDeletionDate = computed(() => {
if (!markedForDeletionDate.value) return '';
return markedForDeletionDate.value.toLocaleString();
});
const markedForDeletionMessage = computed(() => {
const params = { deletionDate: formattedDeletionDate.value };
if (markedForDeletionReason.value === 'manual_deletion') {
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_MANUAL`,
params
);
}
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_INACTIVITY`,
params
);
});
function handleDeletionError(error) {
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
}
async function markAccountForDeletion() {
toggleDeletePopup(false);
try {
// Use the enterprise API to toggle deletion with delete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
handleDeletionError(error);
}
}
async function clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
}
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE')"
:description="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE')"
with-border
>
<div v-if="isMarkedForDeletion">
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-n-ruby-4 rounded">
<p class="mb-4">
{{ markedForDeletionMessage }}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="toggleDeletePopup(true)"
/>
</div>
</SectionLayout>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')"
:confirm-value="currentAccount.name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="toggleDeletePopup(false)"
/>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE')"
with-border
>
<woot-code :script="getAccountId" />
</SectionLayout>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>

View File

@@ -0,0 +1,213 @@
<script setup>
import { h, ref, watch, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n();
const duration = ref(0);
const unit = ref(DURATION_UNITS.MINUTES);
const message = ref('');
const labelToApply = ref({});
const ignoreWaiting = ref(false);
const isEnabled = ref(false);
const isSubmitting = ref(false);
const { currentAccount, updateAccount } = useAccount();
const labels = useMapGetter('labels/getLabels');
const labelOptions = computed(() =>
labels.value?.length
? labels.value.map(label => ({
id: label.title,
name: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
}))
: []
);
const selectedLabelName = computed(() => {
return labelToApply.value?.name ?? null;
});
watch(
[currentAccount, labelOptions],
() => {
const {
auto_resolve_after,
auto_resolve_message,
auto_resolve_ignore_waiting,
auto_resolve_label,
} = currentAccount.value?.settings || {};
duration.value = auto_resolve_after;
message.value = auto_resolve_message;
ignoreWaiting.value = auto_resolve_ignore_waiting;
// find the correct label option from the list
// the single select component expects the full label object
// in our case, the label id and name are both the same
labelToApply.value = labelOptions.value.find(
option => option.name === auto_resolve_label
);
// Set unit based on duration and its divisibility
if (duration.value) {
if (duration.value % (24 * 60) === 0) {
unit.value = DURATION_UNITS.DAYS;
} else if (duration.value % 60 === 0) {
unit.value = DURATION_UNITS.HOURS;
} else {
unit.value = DURATION_UNITS.MINUTES;
}
}
if (duration.value) {
isEnabled.value = true;
}
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
isSubmitting.value = true;
await updateAccount(settings, { silent: true });
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
} finally {
isSubmitting.value = false;
}
};
const handleSubmit = async () => {
if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
return Promise.resolve();
}
return updateAccountSettings({
auto_resolve_after: duration.value,
auto_resolve_message: message.value,
auto_resolve_ignore_waiting: ignoreWaiting.value,
auto_resolve_label: selectedLabelName.value,
});
};
const handleDisable = async () => {
duration.value = null;
message.value = '';
return updateAccountSettings({
auto_resolve_after: null,
auto_resolve_message: '',
auto_resolve_ignore_waiting: false,
auto_resolve_label: null,
});
};
const toggleAutoResolve = async () => {
if (!isEnabled.value) handleDisable();
};
</script>
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-heading-2 text-n-slate-12">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
</h3>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</div>
<p class="mb-0 text-body-para text-n-slate-11">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
</p>
</div>
<div v-if="isEnabled" class="px-5 py-4">
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
>
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
v-model:unit="unit"
min="0"
max="1438560"
class="w-full"
/>
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
>
<TextArea
v-model="message"
class="w-full"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
"
/>
</WithLabel>
<WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
<div
class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL')
}}
</span>
<Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:is-loading="isSubmitting"
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
/>
</div>
</form>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useI18n } from 'vue-i18n';
import semver from 'semver';
const { t } = useI18n();
const { currentAccount } = useAccount();
const latestChatwootVersion = computed(() => {
return currentAccount.value.latest_chatwoot_version;
});
const globalConfig = useMapGetter('globalConfig/get');
const hasAnUpdateAvailable = computed(() => {
if (!semver.valid(latestChatwootVersion.value)) {
return false;
}
return semver.lt(globalConfig.value.appVersion, latestChatwootVersion.value);
});
const gitSha = computed(() => {
return globalConfig.value.gitSha.substring(0, 7);
});
const copyGitSha = () => {
copyTextToClipboard(globalConfig.value.gitSha);
};
</script>
<template>
<div class="p-4 text-sm text-center">
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="divide-x divide-n-slate-9">
<span class="px-2">{{ `v${globalConfig.appVersion}` }}</span>
<span
v-tooltip="t('COMPONENTS.CODE.BUTTON_TEXT')"
class="px-2 build-id cursor-pointer"
@click="copyGitSha"
>
{{ `Build ${gitSha}` }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script setup>
import { useI18n } from 'vue-i18n';
defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
withBorder: { type: Boolean, default: false },
hideContent: { type: Boolean, default: false },
beta: { type: Boolean, default: false },
});
const { t } = useI18n();
</script>
<template>
<section
class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
:class="{
'border-t border-n-weak': withBorder,
'pb-8': !hideContent,
}"
>
<header class="grid grid-cols-4">
<div
v-if="
title || beta || $slots.title || description || $slots.description
"
class="col-span-3"
>
<h4
v-if="title || beta || $slots.title"
class="text-heading-2 text-n-slate-12 flex items-center gap-2"
>
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
class="text-xs uppercase text-n-iris-11 border border-1 border-n-iris-10 leading-none rounded-lg px-1 py-0.5"
>
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p
v-if="description || $slots.description"
class="text-n-slate-11 text-body-main mt-2"
>
<slot name="description">{{ description }}</slot>
</p>
</div>
<div class="col-span-1">
<slot name="headerActions" />
</div>
</header>
<div
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
>
<slot />
</div>
</section>
</template>

View File

@@ -0,0 +1,216 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AgentBotModal from './components/AgentBotModal.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = {
CREATE: 'create',
EDIT: 'edit',
};
const store = useStore();
const { t } = useI18n();
const agentBots = useMapGetter('agentBots/getBots');
const uiFlags = useMapGetter('agentBots/getUIFlags');
const selectedBot = ref({});
const searchQuery = ref('');
const loading = ref({});
const modalType = ref(MODAL_TYPES.CREATE);
const agentBotModalRef = ref(null);
const agentBotDeleteDialogRef = ref(null);
const tableHeaders = computed(() => {
return [
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
t('AGENT_BOTS.LIST.TABLE_HEADER.ACTIONS'),
];
});
const selectedBotName = computed(() => selectedBot.value?.name || '');
const filteredAgentBots = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentBots.value;
return picoSearch(agentBots.value, query, ['name', 'description']);
});
const openAddModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
agentBotModalRef.value.dialogRef.open();
};
const openEditModal = bot => {
modalType.value = MODAL_TYPES.EDIT;
selectedBot.value = bot;
agentBotModalRef.value.dialogRef.open();
};
const openDeletePopup = bot => {
selectedBot.value = bot;
agentBotDeleteDialogRef.value.open();
};
const deleteAgentBot = async id => {
try {
await store.dispatch('agentBots/delete', id);
useAlert(t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
} finally {
loading.value[id] = false;
selectedBot.value = {};
}
};
const confirmDeletion = () => {
loading.value[selectedBot.value.id] = true;
deleteAgentBot(selectedBot.value.id);
agentBotDeleteDialogRef.value.close();
};
onMounted(() => {
store.dispatch('agentBots/get');
});
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="t('AGENT_BOTS.LIST.LOADING')"
:no-records-found="!agentBots.length"
:no-records-message="t('AGENT_BOTS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="t('AGENT_BOTS.HEADER')"
:description="t('AGENT_BOTS.DESCRIPTION')"
:link-text="t('AGENT_BOTS.LEARN_MORE')"
:search-placeholder="t('AGENT_BOTS.SEARCH_PLACEHOLDER')"
feature-name="agent_bots"
>
<template v-if="agentBots?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('AGENT_BOTS.COUNT', { n: agentBots.length }) }}
</span>
</template>
<template #actions>
<Button
:label="$t('AGENT_BOTS.ADD.TITLE')"
size="sm"
@click="openAddModal"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<BaseTable
:headers="tableHeaders"
:items="filteredAgentBots"
:no-data-message="
searchQuery ? t('AGENT_BOTS.NO_RESULTS') : t('AGENT_BOTS.LIST.404')
"
>
<template #row="{ items }">
<BaseTableRow v-for="bot in items" :key="bot.id" :item="bot">
<template #default>
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-12 truncate">
{{ bot.name }}
</span>
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 rounded-md py-0.5 px-1 flex-shrink-0"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
</span>
</div>
<span class="text-body-main text-n-slate-11 block truncate">
{{ bot.description }}
</span>
</div>
</div>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<span class="text-body-main text-n-slate-11 truncate block">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<AgentBotModal
ref="agentBotModalRef"
:type="modalType"
:selected-bot="selectedBot"
/>
<Dialog
ref="agentBotDeleteDialogRef"
type="alert"
:title="t('AGENT_BOTS.DELETE.CONFIRM.TITLE')"
:description="
t('AGENT_BOTS.DELETE.CONFIRM.MESSAGE', { name: selectedBotName })
"
:is-loading="uiFlags.isDeleting"
:confirm-button-label="t('AGENT_BOTS.DELETE.CONFIRM.YES')"
:cancel-button-label="t('AGENT_BOTS.DELETE.CONFIRM.NO')"
@confirm="confirmDeletion"
/>
</SettingsLayout>
</template>

View File

@@ -0,0 +1,27 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import Bot from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsWrapper from '../SettingsWrapper.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/agent-bots'),
meta: {
permissions: ['administrator'],
},
component: SettingsWrapper,
children: [
{
path: '',
name: 'agent_bots',
component: Bot,
meta: {
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
permissions: ['administrator'],
},
},
],
},
],
};

Some files were not shown because too many files have changed in this diff Show More