Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export const getPortalRoute = (path = '') => {
|
||||
const slugToBeAdded = path ? `/${path}` : '';
|
||||
return frontendURL(`accounts/:accountId/portals${slugToBeAdded}`);
|
||||
};
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user