Merge branch 'develop' into fix/hc-editor
This commit is contained in:
@@ -1,69 +1,73 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
|
||||
import NextSidebar from 'next/sidebar/Sidebar.vue';
|
||||
import Sidebar from '../../components/layout/Sidebar.vue';
|
||||
import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal.vue';
|
||||
import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
|
||||
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
|
||||
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.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';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const CommandBar = defineAsyncComponent(
|
||||
() => import('./commands/commandbar.vue')
|
||||
);
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextSidebar,
|
||||
Sidebar,
|
||||
CommandBar,
|
||||
WootKeyShortcutModal,
|
||||
AddAccountModal,
|
||||
AccountSelector,
|
||||
AddLabelModal,
|
||||
NotificationPanel,
|
||||
UpgradePage,
|
||||
CopilotLauncher,
|
||||
CopilotContainer,
|
||||
MobileSidebarLauncher,
|
||||
},
|
||||
setup() {
|
||||
const upgradePageRef = ref(null);
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const { accountId } = useAccount();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
accountId,
|
||||
upgradePageRef,
|
||||
windowWidth,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAccountModal: false,
|
||||
showCreateAccountModal: false,
|
||||
showAddLabelModal: false,
|
||||
showShortcutModal: false,
|
||||
isNotificationPanel: false,
|
||||
displayLayoutType: '',
|
||||
hasBanner: '',
|
||||
isMobileSidebarOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
currentRoute() {
|
||||
return ' ';
|
||||
isSmallScreen() {
|
||||
return this.windowWidth < wootConstants.SMALL_SCREEN_BREAKPOINT;
|
||||
},
|
||||
isSidebarOpen() {
|
||||
const { show_secondary_sidebar: showSecondarySidebar } = this.uiSettings;
|
||||
return showSecondarySidebar;
|
||||
showUpgradePage() {
|
||||
return this.upgradePageRef?.shouldShowUpgradePage;
|
||||
},
|
||||
bypassUpgradePage() {
|
||||
return [
|
||||
'billing_settings_index',
|
||||
'settings_inbox_list',
|
||||
'general_settings_index',
|
||||
'agent_list',
|
||||
].includes(this.$route.name);
|
||||
},
|
||||
previouslyUsedDisplayType() {
|
||||
const {
|
||||
@@ -71,75 +75,30 @@ export default {
|
||||
} = this.uiSettings;
|
||||
return conversationDisplayType;
|
||||
},
|
||||
previouslyUsedSidebarView() {
|
||||
const { previously_used_sidebar_view: showSecondarySidebar } =
|
||||
this.uiSettings;
|
||||
return showSecondarySidebar;
|
||||
},
|
||||
showNextSidebar() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
displayLayoutType() {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
this.updateUISettings({
|
||||
conversation_display_type:
|
||||
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
|
||||
? LAYOUT_TYPES.EXPANDED
|
||||
: this.previouslyUsedDisplayType,
|
||||
show_secondary_sidebar:
|
||||
this.displayLayoutType === LAYOUT_TYPES.EXPANDED
|
||||
? false
|
||||
: this.previouslyUsedSidebarView,
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.handleResize();
|
||||
this.$nextTick(this.checkBanner);
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
window.addEventListener('resize', this.checkBanner);
|
||||
emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
|
||||
},
|
||||
unmounted() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('resize', this.checkBanner);
|
||||
emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
|
||||
},
|
||||
|
||||
methods: {
|
||||
checkBanner() {
|
||||
this.hasBanner =
|
||||
document.getElementsByClassName('woot-banner').length > 0;
|
||||
},
|
||||
handleResize() {
|
||||
const { SMALL_SCREEN_BREAKPOINT, LAYOUT_TYPES } = wootConstants;
|
||||
let throttled = false;
|
||||
const delay = 150;
|
||||
|
||||
if (throttled) {
|
||||
return;
|
||||
}
|
||||
throttled = true;
|
||||
|
||||
setTimeout(() => {
|
||||
throttled = false;
|
||||
if (window.innerWidth <= SMALL_SCREEN_BREAKPOINT) {
|
||||
this.displayLayoutType = LAYOUT_TYPES.EXPANDED;
|
||||
isSmallScreen: {
|
||||
handler() {
|
||||
const { LAYOUT_TYPES } = wootConstants;
|
||||
if (window.innerWidth <= wootConstants.SMALL_SCREEN_BREAKPOINT) {
|
||||
this.updateUISettings({
|
||||
conversation_display_type: LAYOUT_TYPES.EXPANDED,
|
||||
});
|
||||
} else {
|
||||
this.displayLayoutType = LAYOUT_TYPES.CONDENSED;
|
||||
this.updateUISettings({
|
||||
conversation_display_type: this.previouslyUsedDisplayType,
|
||||
});
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
toggleSidebar() {
|
||||
this.updateUISettings({
|
||||
show_secondary_sidebar: !this.isSidebarOpen,
|
||||
previously_used_sidebar_view: !this.isSidebarOpen,
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
toggleMobileSidebar() {
|
||||
this.isMobileSidebarOpen = !this.isMobileSidebarOpen;
|
||||
},
|
||||
closeMobileSidebar() {
|
||||
this.isMobileSidebarOpen = false;
|
||||
},
|
||||
openCreateAccountModal() {
|
||||
this.showAccountModal = false;
|
||||
@@ -157,50 +116,42 @@ export default {
|
||||
closeKeyShortcutModal() {
|
||||
this.showShortcutModal = false;
|
||||
},
|
||||
showAddLabelPopup() {
|
||||
this.showAddLabelModal = true;
|
||||
},
|
||||
hideAddLabelPopup() {
|
||||
this.showAddLabelModal = false;
|
||||
},
|
||||
openNotificationPanel() {
|
||||
this.isNotificationPanel = true;
|
||||
},
|
||||
closeNotificationPanel() {
|
||||
this.isNotificationPanel = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap app-wrapper dark:text-slate-300">
|
||||
<div class="flex flex-grow overflow-hidden text-n-slate-12">
|
||||
<NextSidebar
|
||||
v-if="showNextSidebar"
|
||||
: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"
|
||||
/>
|
||||
<Sidebar
|
||||
v-else
|
||||
:route="currentRoute"
|
||||
:has-banner="hasBanner"
|
||||
:show-secondary-sidebar="isSidebarOpen"
|
||||
@open-notification-panel="openNotificationPanel"
|
||||
@toggle-account-modal="toggleAccountModal"
|
||||
@open-key-shortcut-modal="toggleKeyShortcutModal"
|
||||
@close-key-shortcut-modal="closeKeyShortcutModal"
|
||||
@show-add-label-popup="showAddLabelPopup"
|
||||
/>
|
||||
<main class="flex flex-1 h-full min-h-0 px-0 overflow-hidden">
|
||||
<router-view />
|
||||
<CommandBar />
|
||||
<AccountSelector
|
||||
:show-account-modal="showAccountModal"
|
||||
@close-account-modal="toggleAccountModal"
|
||||
@show-create-account-modal="openCreateAccountModal"
|
||||
/>
|
||||
|
||||
<main class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden">
|
||||
<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 />
|
||||
</template>
|
||||
<AddAccountModal
|
||||
:show="showCreateAccountModal"
|
||||
@close-account-create-modal="closeCreateAccountModal"
|
||||
@@ -210,16 +161,6 @@ export default {
|
||||
@close="closeKeyShortcutModal"
|
||||
@clickaway="closeKeyShortcutModal"
|
||||
/>
|
||||
<NotificationPanel
|
||||
v-if="isNotificationPanel"
|
||||
@close="closeNotificationPanel"
|
||||
/>
|
||||
<woot-modal
|
||||
v-model:show="showAddLabelModal"
|
||||
:on-close="hideAddLabelPopup"
|
||||
>
|
||||
<AddLabelModal @close="hideAddLabelPopup" />
|
||||
</woot-modal>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,13 @@ 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: [
|
||||
@@ -19,9 +26,7 @@ const campaignsRoutes = {
|
||||
{
|
||||
path: 'ongoing',
|
||||
name: 'campaigns_ongoing_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
redirect: to => {
|
||||
return { name: 'campaigns_livechat_index', params: to.params };
|
||||
},
|
||||
@@ -29,9 +34,7 @@ const campaignsRoutes = {
|
||||
{
|
||||
path: 'one_off',
|
||||
name: 'campaigns_one_off_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
redirect: to => {
|
||||
return { name: 'campaigns_sms_index', params: to.params };
|
||||
},
|
||||
@@ -39,19 +42,24 @@ const campaignsRoutes = {
|
||||
{
|
||||
path: 'live_chat',
|
||||
name: 'campaigns_livechat_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
component: LiveChatCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'campaigns_sms_index',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
meta,
|
||||
component: SMSCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'whatsapp',
|
||||
name: 'campaigns_whatsapp_index',
|
||||
meta: {
|
||||
...meta,
|
||||
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
|
||||
},
|
||||
component: WhatsAppCampaignsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="keepAlive">
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
@@ -25,8 +24,8 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
|
||||
|
||||
const liveChatCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
|
||||
const liveChatCampaigns = computed(
|
||||
() => getters['campaigns/getLiveChatCampaigns'].value
|
||||
);
|
||||
|
||||
const hasNoLiveChatCampaigns = computed(
|
||||
@@ -59,7 +58,7 @@ const handleDelete = campaign => {
|
||||
|
||||
<div
|
||||
v-if="isFetchingCampaigns"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
class="flex justify-center items-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
@@ -23,9 +22,7 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const confirmDeleteCampaignDialogRef = ref(null);
|
||||
|
||||
const SMSCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
|
||||
);
|
||||
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
|
||||
|
||||
const hasNoSMSCampaigns = computed(
|
||||
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
||||
|
||||
@@ -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,86 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import EditAssistantForm from '../../../../components-next/captain/pageComponents/assistant/EditAssistantForm.vue';
|
||||
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
|
||||
|
||||
import AssistantSettings from 'dashboard/routes/dashboard/captain/assistants/settings/Settings.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const isCaptainV2Enabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_V2
|
||||
);
|
||||
|
||||
const isAssistantAvailable = computed(() => !!assistant.value?.id);
|
||||
|
||||
const handleSubmit = async updatedAssistant => {
|
||||
try {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
...updatedAssistant,
|
||||
});
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAssistantAvailable.value || !isCaptainV2Enabled) {
|
||||
store.dispatch('captainAssistants/show', assistantId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AssistantSettings v-if="isCaptainV2Enabled" />
|
||||
<PageLayout
|
||||
v-else
|
||||
:header-title="assistant?.name"
|
||||
:show-pagination-footer="false"
|
||||
:is-fetching="isFetching"
|
||||
:show-know-more="false"
|
||||
:back-url="{ name: 'captain_assistants_index' }"
|
||||
>
|
||||
<template #body>
|
||||
<div v-if="!isAssistantAvailable">
|
||||
{{ t('CAPTAIN.ASSISTANTS.EDIT.NOT_FOUND') }}
|
||||
</div>
|
||||
<div v-else class="flex gap-4 h-full">
|
||||
<div class="flex-1 lg:overflow-auto pr-4 h-full md:h-auto">
|
||||
<EditAssistantForm
|
||||
:assistant="assistant"
|
||||
mode="edit"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[400px] hidden lg:block h-full">
|
||||
<AssistantPlayground :assistant-id="Number(assistantId)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
@@ -35,8 +36,10 @@ const handleCreate = () => {
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createAssistantDialog.value.dialogRef.open());
|
||||
router.push({
|
||||
name: 'captain_assistants_edit',
|
||||
params: { assistantId: selectedAssistant.value.id },
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewConnectedInboxes = () => {
|
||||
@@ -78,10 +81,20 @@ onMounted(() => store.dispatch('captainAssistants/get'));
|
||||
:button-policy="['administrator']"
|
||||
:show-pagination-footer="false"
|
||||
:is-fetching="isFetching"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:is-empty="!assistants.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@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')"
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<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 SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.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 assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const newInlineRule = ref('');
|
||||
const newDialogRule = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BREADCRUMB.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const guardrailsContent = computed(() => assistant.value?.guardrails || []);
|
||||
|
||||
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,
|
||||
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>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<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>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,325 @@
|
||||
<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 SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.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 assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const newInlineRule = ref('');
|
||||
const newDialogRule = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const guidelinesContent = computed(
|
||||
() => assistant.value?.response_guidelines || []
|
||||
);
|
||||
|
||||
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,
|
||||
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>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<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>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -72,14 +72,16 @@ onMounted(() =>
|
||||
:button-policy="['administrator']"
|
||||
:is-fetching="isFetchingAssistant || isFetching"
|
||||
:is-empty="!captainInboxes.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:show-pagination-footer="false"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template v-if="!isFetchingAssistant" #headerTitle>
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<BackButton compact />
|
||||
<span class="flex items-center gap-1 text-lg">
|
||||
<span
|
||||
class="flex items-center gap-1 text-lg font-medium text-n-slate-12"
|
||||
>
|
||||
{{ assistant.name }}
|
||||
<span class="i-lucide-chevron-right text-xl text-n-slate-10" />
|
||||
{{ $t('CAPTAIN.INBOXES.HEADER') }}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<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 Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.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 assistantId = route.params.assistantId;
|
||||
|
||||
const uiFlags = useMapGetter('captainScenarios/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
const scenarios = useMapGetter('captainScenarios/getRecords');
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.SCENARIOS.BREADCRUMB.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const TOOL_LINK_REGEX = /\[([^\]]+)]\(tool:\/\/.+?\)/g;
|
||||
|
||||
const renderInstruction = instruction => () =>
|
||||
h('span', {
|
||||
class: 'text-sm text-n-slate-12 py-4',
|
||||
innerHTML: instruction.replace(
|
||||
TOOL_LINK_REGEX,
|
||||
(_, title) =>
|
||||
`<span class="text-n-iris-11 font-medium">@${title.replace(/^@/, '')}</span>`
|
||||
),
|
||||
});
|
||||
|
||||
// Suggested example scenarios for quick add
|
||||
const scenariosExample = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Refund Order',
|
||||
description: 'User encountered a technical issue or error message.',
|
||||
instruction:
|
||||
'Ask for steps to reproduce + browser/app version. Use [Known Issues](tool://known_issues) to check if it’s a known bug. File with [Create Bug Report](tool://bug_report_create) if new.',
|
||||
tools: ['create_bug_report', 'known_issues'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Product Recommendation',
|
||||
description: 'User is unsure which product or service to choose.',
|
||||
instruction:
|
||||
'Ask 2–3 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 2–3 options with pros/cons. Link to compare page if available.',
|
||||
tools: ['product_match[user_needs]'],
|
||||
},
|
||||
];
|
||||
|
||||
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: route.params.assistantId,
|
||||
...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: route.params.assistantId,
|
||||
});
|
||||
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: route.params.assistantId,
|
||||
})
|
||||
)
|
||||
);
|
||||
bulkSelectedIds.value = new Set();
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.SCENARIOS.API.DELETE.SUCCESS'));
|
||||
};
|
||||
|
||||
const addScenario = async scenario => {
|
||||
try {
|
||||
await store.dispatch('captainScenarios/create', {
|
||||
assistantId: route.params.assistantId,
|
||||
...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: route.params.assistantId,
|
||||
...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,
|
||||
});
|
||||
store.dispatch('captainTools/getTools');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<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(item.instruction)" />
|
||||
<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>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRoute } 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 SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.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';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const isAssistantAvailable = computed(() => !!assistant.value?.id);
|
||||
|
||||
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.SCENARIOS.TITLE'
|
||||
),
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.SCENARIOS.DESCRIPTION'
|
||||
),
|
||||
routeName: 'captain_assistants_scenarios_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 breadcrumbItems = computed(() => {
|
||||
const activeControlItem = controlItems.value?.find(
|
||||
item => item.routeName === route.name
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
...(activeControlItem
|
||||
? [
|
||||
{
|
||||
label: activeControlItem.name,
|
||||
routeName: activeControlItem.routeName,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
});
|
||||
|
||||
const handleSubmit = async updatedAssistant => {
|
||||
try {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
...updatedAssistant,
|
||||
});
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!isAssistantAvailable.value) {
|
||||
store.dispatch('captainAssistants/show', assistantId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
class="[&>div]:max-w-[80rem]"
|
||||
>
|
||||
<template #body>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
<template #controls>
|
||||
<div 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>
|
||||
</template>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -1,7 +1,13 @@
|
||||
// import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantEdit from './assistants/Edit.vue';
|
||||
// import AssistantSettings from './assistants/settings/Settings.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/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';
|
||||
|
||||
@@ -12,6 +18,24 @@ export const routes = [
|
||||
name: 'captain_assistants_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/assistants/:assistantId'),
|
||||
component: AssistantEdit,
|
||||
name: 'captain_assistants_edit',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -22,6 +46,56 @@ export const routes = [
|
||||
name: 'captain_assistants_inboxes_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/guardrails'
|
||||
),
|
||||
component: AssistantGuardrailsIndex,
|
||||
name: 'captain_assistants_guardrails_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/scenarios'
|
||||
),
|
||||
component: AssistantScenariosIndex,
|
||||
name: 'captain_assistants_scenarios_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
|
||||
),
|
||||
component: AssistantGuidelinesIndex,
|
||||
name: 'captain_assistants_guidelines_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -30,6 +104,11 @@ export const routes = [
|
||||
name: 'captain_documents_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -38,6 +117,11 @@ export const routes = [
|
||||
name: 'captain_responses_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import RelatedResponses from 'dashboard/components-next/captain/pageComponents/d
|
||||
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.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 store = useStore();
|
||||
@@ -115,6 +116,17 @@ onMounted(() => {
|
||||
@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')"
|
||||
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>
|
||||
|
||||
@@ -8,14 +8,17 @@ import { useRouter } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.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 AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.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();
|
||||
@@ -28,6 +31,7 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedStatus = ref('all');
|
||||
const selectedAssistant = ref('all');
|
||||
@@ -51,6 +55,12 @@ const statusOptions = computed(() =>
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredResponses = computed(() => {
|
||||
return selectedStatus.value === 'pending'
|
||||
? responses.value.filter(r => r.status === 'pending')
|
||||
: responses.value;
|
||||
});
|
||||
|
||||
const selectedStatusLabel = computed(() => {
|
||||
const status = statusOptions.value.find(
|
||||
option => option.value === selectedStatus.value
|
||||
@@ -90,7 +100,9 @@ const handleEdit = () => {
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = responses.value.find(response => id === response.id);
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
@@ -129,14 +141,104 @@ const fetchResponses = (page = 1) => {
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
const onPageChange = page => fetchResponses(page);
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
|
||||
allSelected: totalCount > 0 && selectedCount === totalCount,
|
||||
};
|
||||
});
|
||||
|
||||
const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
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) {
|
||||
// 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 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 => {
|
||||
// Store current selection state before fetching new page
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
const hadPartialSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
// Reset selection if we had any selections on page change
|
||||
if (wasAllPageSelected || hadPartialSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = ({ value }) => {
|
||||
selectedStatus.value = value;
|
||||
isStatusFilterOpen.value = false;
|
||||
@@ -162,12 +264,23 @@ onMounted(() => {
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!responses.length"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
@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')"
|
||||
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 #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
@@ -177,29 +290,91 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-4 -mt-3 flex justify-between items-center w-fit py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3':
|
||||
bulkSelectionState.hasSelected,
|
||||
}"
|
||||
>
|
||||
<div v-if="!bulkSelectionState.hasSelected" class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||
enter-to-class="opacity-100 transform translate-x-0"
|
||||
leave-active-class="hidden opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||
{{ buildSelectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.size,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -208,7 +383,7 @@ onMounted(() => {
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
@@ -218,8 +393,13 @@ onMounted(() => {
|
||||
: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)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -232,6 +412,14 @@ onMounted(() => {
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
|
||||
@@ -95,7 +95,7 @@ onMounted(setCommandBarData);
|
||||
|
||||
<style lang="scss">
|
||||
ninja-keys {
|
||||
--ninja-accent-color: var(--w-500);
|
||||
--ninja-accent-color: rgba(39, 129, 246, 1);
|
||||
--ninja-font-family: 'Inter';
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<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';
|
||||
|
||||
@@ -25,6 +26,7 @@ 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));
|
||||
|
||||
@@ -88,6 +90,33 @@ 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();
|
||||
@@ -105,7 +134,9 @@ onMounted(() => {
|
||||
: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"
|
||||
|
||||
@@ -67,9 +67,11 @@ 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 &&
|
||||
@@ -89,11 +91,20 @@ const showEmptyText = computed(() => {
|
||||
|
||||
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 updatePageParam = (page, search = '') => {
|
||||
const query = {
|
||||
...route.query,
|
||||
@@ -132,6 +143,15 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const fetchActiveContacts = async (page = 1) => {
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/active', {
|
||||
page,
|
||||
sortAttr: buildSortAttr(),
|
||||
});
|
||||
updatePageParam(page);
|
||||
};
|
||||
|
||||
const searchContacts = debounce(async (value, page = 1) => {
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
searchValue.value = value;
|
||||
@@ -158,6 +178,11 @@ const fetchContactsBasedOnContext = async page => {
|
||||
}
|
||||
// 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) &&
|
||||
@@ -184,6 +209,11 @@ const handleSort = async ({ sort, order }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveView.value) {
|
||||
await fetchActiveContacts();
|
||||
return;
|
||||
}
|
||||
|
||||
await (activeSegmentId.value || hasAppliedFilters.value
|
||||
? fetchSavedOrAppliedFilteredContact(
|
||||
activeSegmentId.value
|
||||
@@ -210,7 +240,7 @@ watch(
|
||||
);
|
||||
|
||||
watch(
|
||||
[activeLabel, activeSegment],
|
||||
[activeLabel, activeSegment, isActiveView],
|
||||
() => {
|
||||
fetchContactsBasedOnContext(pageNumber.value);
|
||||
},
|
||||
@@ -222,6 +252,13 @@ watch(searchQuery, value => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -232,6 +269,10 @@ onMounted(async () => {
|
||||
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(
|
||||
@@ -286,11 +327,7 @@ onMounted(async () => {
|
||||
class="flex items-center justify-center py-10"
|
||||
>
|
||||
<span class="text-base text-n-slate-11">
|
||||
{{
|
||||
searchQuery || !hasAppliedFilters
|
||||
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
|
||||
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
|
||||
}}
|
||||
{{ emptyStateMessage }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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'],
|
||||
};
|
||||
|
||||
@@ -30,6 +32,12 @@ export const routes = [
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
{
|
||||
path: 'active',
|
||||
name: 'contacts_dashboard_active',
|
||||
component: ContactsIndex,
|
||||
meta: commonMeta,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -47,40 +47,32 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-conversation--panel">
|
||||
<div v-if="!uiFlags.isFetching" class="contact-conversation__wrap">
|
||||
<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
|
||||
class="compact"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<Spinner v-else />
|
||||
<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-slate-500 dark:text-slate-400 mb-4;
|
||||
}
|
||||
|
||||
::v-deep .conversation {
|
||||
@apply pr-0;
|
||||
.conversation--details {
|
||||
@apply pl-2;
|
||||
}
|
||||
@apply text-n-slate-11 mb-4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
<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-slate-800 dark:text-slate-100">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ title }}
|
||||
</span>
|
||||
<slot name="button" />
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
<script setup>
|
||||
import { computed, watch, onMounted, ref } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import {
|
||||
useMapGetter,
|
||||
useFunctionGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
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';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const props = defineProps({
|
||||
conversationId: {
|
||||
@@ -23,10 +32,6 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
onToggle: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -39,6 +44,35 @@ const {
|
||||
const dragging = ref(false);
|
||||
const conversationSidebarItems = ref([]);
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const shopifyIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'shopify'
|
||||
);
|
||||
|
||||
const isShopifyFeatureEnabled = computed(
|
||||
() => shopifyIntegration.value.enabled
|
||||
);
|
||||
|
||||
const linearIntegration = useFunctionGetter(
|
||||
'integrations/getIntegration',
|
||||
'linear'
|
||||
);
|
||||
|
||||
const isLinearIntegrationEnabled = computed(
|
||||
() => linearIntegration.value?.enabled || false
|
||||
);
|
||||
|
||||
const isLinearFeatureEnabled = isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.LINEAR
|
||||
);
|
||||
|
||||
const store = useStore();
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationId = computed(() => props.conversationId);
|
||||
@@ -67,16 +101,12 @@ const getContactDetails = () => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(conversationId, (newConversationId, prevConversationId) => {
|
||||
if (newConversationId && newConversationId !== prevConversationId) {
|
||||
watch(contactId, (newContactId, prevContactId) => {
|
||||
if (newContactId && newContactId !== prevContactId) {
|
||||
getContactDetails();
|
||||
}
|
||||
});
|
||||
|
||||
watch(contactId, getContactDetails);
|
||||
|
||||
const onPanelToggle = props.onToggle;
|
||||
|
||||
const onDragEnd = () => {
|
||||
dragging.value = false;
|
||||
updateUISettings({
|
||||
@@ -84,21 +114,30 @@ const onDragEnd = () => {
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
<ContactInfo
|
||||
:contact="contact"
|
||||
:channel-type="channelType"
|
||||
@toggle-panel="onPanelToggle"
|
||||
<SidebarActionsHeader
|
||||
:title="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
@close="closeContactPanel"
|
||||
/>
|
||||
<div class="list-group pb-8">
|
||||
<ContactInfo :contact="contact" :channel-type="channelType" />
|
||||
<div class="pb-8 list-group px-2">
|
||||
<Draggable
|
||||
:list="conversationSidebarItems"
|
||||
animation="200"
|
||||
@@ -110,112 +149,151 @@ onMounted(() => {
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div :key="element.name" class="px-2">
|
||||
<div
|
||||
v-if="element.name === 'conversation_actions'"
|
||||
class="conversation--actions"
|
||||
<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)
|
||||
"
|
||||
>
|
||||
<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"
|
||||
<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)
|
||||
"
|
||||
>
|
||||
<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"
|
||||
<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)
|
||||
"
|
||||
>
|
||||
<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>
|
||||
<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
|
||||
"
|
||||
>
|
||||
<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="!isLinearIntegrationEnabled" />
|
||||
<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>
|
||||
@@ -226,7 +304,7 @@ onMounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
::v-deep {
|
||||
.contact--profile {
|
||||
@apply pb-3 border-b border-solid border-slate-75 dark:border-slate-700;
|
||||
@apply pb-3 border-b border-solid border-n-weak;
|
||||
}
|
||||
|
||||
.conversation--actions .multiselect-wrap--small {
|
||||
|
||||
@@ -9,12 +9,14 @@ 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: {
|
||||
@@ -205,22 +207,22 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white dark:bg-slate-900">
|
||||
<div class="bg-n-background">
|
||||
<div class="multiselect-wrap--small">
|
||||
<ContactDetailsItem
|
||||
compact
|
||||
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
|
||||
>
|
||||
<template #button>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showSelfAssign"
|
||||
icon="arrow-right"
|
||||
variant="link"
|
||||
size="small"
|
||||
link
|
||||
xs
|
||||
icon="i-lucide-arrow-right"
|
||||
class="!gap-1"
|
||||
:label="$t('CONVERSATION_SIDEBAR.SELF_ASSIGN')"
|
||||
@click="onSelfAssign"
|
||||
>
|
||||
{{ $t('CONVERSATION_SIDEBAR.SELF_ASSIGN') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</template>
|
||||
</ContactDetailsItem>
|
||||
<MultiselectDropdown
|
||||
@@ -251,7 +253,7 @@ export default {
|
||||
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.NO_RESULTS.TEAM')
|
||||
"
|
||||
:input-placeholder="
|
||||
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.INPUT')
|
||||
$t('AGENT_MGMT.MULTI_SELECTOR.SEARCH.PLACEHOLDER.TEAM')
|
||||
"
|
||||
@select="onClickAssignTeam"
|
||||
/>
|
||||
|
||||
+25
-34
@@ -6,12 +6,14 @@ 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: {
|
||||
@@ -153,7 +155,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative bg-white dark:bg-slate-900">
|
||||
<div class="relative bg-n-background">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex justify-between w-full mb-1">
|
||||
<div>
|
||||
@@ -161,17 +163,18 @@ export default {
|
||||
<Spinner v-if="watchersUiFlas.isFetching" size="tiny" />
|
||||
{{ totalWatchersText }}
|
||||
</p>
|
||||
<p v-else class="m-0 text-sm text-slate-400 dark:text-slate-700">
|
||||
<p v-else class="m-0 text-sm text-n-slate-10">
|
||||
{{ $t('CONVERSATION_PARTICIPANTS.NO_PARTICIPANTS_TEXT') }}
|
||||
</p>
|
||||
</div>
|
||||
<woot-button
|
||||
<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')"
|
||||
icon="settings"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
@click="onOpenDropdown"
|
||||
/>
|
||||
</div>
|
||||
@@ -182,21 +185,18 @@ export default {
|
||||
:show-more-thumbnails-count="showMoreThumbs"
|
||||
:users-list="thumbnailList"
|
||||
/>
|
||||
<p
|
||||
v-if="isUserWatching"
|
||||
class="m-0 text-sm text-slate-300 dark:text-slate-300"
|
||||
>
|
||||
<p v-if="isUserWatching" class="m-0 text-sm text-n-slate-10">
|
||||
{{ $t('CONVERSATION_PARTICIPANTS.YOU_ARE_WATCHING') }}
|
||||
</p>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-else
|
||||
icon="arrow-right"
|
||||
variant="link"
|
||||
size="small"
|
||||
link
|
||||
xs
|
||||
icon="i-lucide-arrow-right"
|
||||
class="!gap-1"
|
||||
:label="$t('CONVERSATION_PARTICIPANTS.WATCH_CONVERSATION')"
|
||||
@click="onSelfAssign"
|
||||
>
|
||||
{{ $t('CONVERSATION_PARTICIPANTS.WATCH_CONVERSATION') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-on-clickaway="
|
||||
@@ -204,22 +204,19 @@ export default {
|
||||
onCloseDropdown();
|
||||
}
|
||||
"
|
||||
:class="{ 'dropdown-pane--open': showDropDown }"
|
||||
class="dropdown-pane"
|
||||
: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-slate-800 dark:text-slate-100"
|
||||
class="m-0 overflow-hidden text-sm whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||
>
|
||||
{{ $t('CONVERSATION_PARTICIPANTS.ADD_PARTICIPANTS') }}
|
||||
</h4>
|
||||
<woot-button
|
||||
icon="dismiss"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
@click="onCloseDropdown"
|
||||
/>
|
||||
<NextButton ghost slate xs icon="i-lucide-x" @click="onCloseDropdown" />
|
||||
</div>
|
||||
<MultiselectDropdownItems
|
||||
:options="agentsList"
|
||||
@@ -230,9 +227,3 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dropdown-pane {
|
||||
@apply box-border top-8 w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,19 +4,20 @@ 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 PopOverSearch from './search/PopOverSearch.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 { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
|
||||
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatList,
|
||||
ConversationBox,
|
||||
PopOverSearch,
|
||||
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
|
||||
@@ -71,7 +72,6 @@ export default {
|
||||
...mapGetters({
|
||||
chatList: 'getAllConversations',
|
||||
currentChat: 'getSelectedChat',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
showConversationList() {
|
||||
return this.isOnExpandedLayout ? !this.conversationId : true;
|
||||
@@ -87,19 +87,14 @@ export default {
|
||||
this.uiSettings;
|
||||
return conversationDisplayType !== CONDENSED;
|
||||
},
|
||||
isContactPanelOpen() {
|
||||
if (this.currentChat.id) {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
||||
this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
|
||||
shouldShowSidebar() {
|
||||
if (!this.currentChat.id) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
showPopOverSearch() {
|
||||
return !this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CHATWOOT_V4
|
||||
);
|
||||
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -120,6 +115,7 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
this.$store.dispatch('portals/index');
|
||||
this.initialize();
|
||||
this.$watch('$store.state.route', () => this.initialize());
|
||||
this.$watch('chatList.length', () => {
|
||||
@@ -188,11 +184,6 @@ export default {
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
}
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.updateUISettings({
|
||||
is_contact_sidebar_open: !this.isContactPanelOpen,
|
||||
});
|
||||
},
|
||||
onSearch() {
|
||||
this.showSearchModal = true;
|
||||
},
|
||||
@@ -204,7 +195,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex w-full h-full">
|
||||
<section class="flex w-full h-full min-w-0">
|
||||
<ChatList
|
||||
:show-conversation-list="showConversationList"
|
||||
:conversation-inbox="inboxId"
|
||||
@@ -214,20 +205,15 @@ export default {
|
||||
:folders-id="foldersId"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@conversation-load="onConversationLoad"
|
||||
>
|
||||
<PopOverSearch
|
||||
v-if="showPopOverSearch"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle-conversation-layout="toggleConversationLayout"
|
||||
/>
|
||||
</ChatList>
|
||||
/>
|
||||
<ConversationBox
|
||||
v-if="showMessageView"
|
||||
:inbox-id="inboxId"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
>
|
||||
<SidepanelSwitch v-if="currentChat.id" />
|
||||
</ConversationBox>
|
||||
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
|
||||
<CmdBarConversationSnooze />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,83 +1,117 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MacroItem,
|
||||
defineProps({
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { accountScopedUrl } = useAccount();
|
||||
});
|
||||
|
||||
return {
|
||||
accountScopedUrl,
|
||||
};
|
||||
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;
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
macros: ['macros/getMacros'],
|
||||
uiFlags: 'macros/getUIFlags',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('macros/get');
|
||||
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="macros_list--empty-state"
|
||||
>
|
||||
<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')">
|
||||
<woot-button
|
||||
variant="smooth"
|
||||
icon="add"
|
||||
size="tiny"
|
||||
class="macros_add-button"
|
||||
>
|
||||
{{ $t('MACROS.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
<NextButton
|
||||
faded
|
||||
xs
|
||||
icon="i-lucide-plus"
|
||||
class="mt-1"
|
||||
:label="$t('MACROS.HEADER_BTN_TXT')"
|
||||
/>
|
||||
</router-link>
|
||||
</div>
|
||||
<woot-loading-state
|
||||
<div
|
||||
v-if="uiFlags.isFetching"
|
||||
:message="$t('MACROS.LOADING')"
|
||||
/>
|
||||
<div v-if="!uiFlags.isFetching && macros.length" class="macros-list">
|
||||
<MacroItem
|
||||
v-for="macro in macros"
|
||||
:key="macro.id"
|
||||
:macro="macro"
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
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">
|
||||
.macros-list {
|
||||
padding: var(--space-smaller);
|
||||
}
|
||||
.macros_list--empty-state {
|
||||
padding: var(--space-slab);
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.macros_add-button {
|
||||
margin: var(--space-small) auto 0;
|
||||
.ghost {
|
||||
@apply opacity-50 bg-n-slate-3 dark:bg-n-slate-9;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,75 +1,81 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import MacroPreview from './MacroPreview.vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { CONVERSATION_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MacroPreview,
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MacroPreview from './MacroPreview.vue';
|
||||
|
||||
const props = defineProps({
|
||||
macro: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
props: {
|
||||
macro: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isExecuting: false,
|
||||
showPreview: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async executeMacro(macro) {
|
||||
try {
|
||||
this.isExecuting = true;
|
||||
await this.$store.dispatch('macros/execute', {
|
||||
macroId: macro.id,
|
||||
conversationIds: [this.conversationId],
|
||||
});
|
||||
useTrack(CONVERSATION_EVENTS.EXECUTED_A_MACRO);
|
||||
useAlert(this.$t('MACROS.EXECUTE.EXECUTED_SUCCESSFULLY'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('MACROS.ERROR'));
|
||||
} finally {
|
||||
this.isExecuting = false;
|
||||
}
|
||||
},
|
||||
toggleMacroPreview() {
|
||||
this.showPreview = !this.showPreview;
|
||||
},
|
||||
closeMacroPreview() {
|
||||
this.showPreview = false;
|
||||
},
|
||||
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="macro button secondary clear">
|
||||
<span class="overflow-hidden whitespace-nowrap text-ellipsis">{{
|
||||
macro.name
|
||||
}}</span>
|
||||
<div class="flex items-center gap-1 macros-actions">
|
||||
<woot-button
|
||||
<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')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="info"
|
||||
@click="toggleMacroPreview(macro)"
|
||||
icon="i-lucide-info"
|
||||
slate
|
||||
faded
|
||||
xs
|
||||
@click="toggleMacroPreview"
|
||||
/>
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-tooltip.left-start="$t('MACROS.EXECUTE.BUTTON_TOOLTIP')"
|
||||
size="tiny"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
icon="play-circle"
|
||||
icon="i-lucide-play"
|
||||
slate
|
||||
faded
|
||||
xs
|
||||
:is-loading="isExecuting"
|
||||
@click="executeMacro(macro)"
|
||||
/>
|
||||
@@ -83,13 +89,3 @@ export default {
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.macro {
|
||||
@apply relative flex items-center justify-between leading-4 rounded-md;
|
||||
|
||||
.macros-actions {
|
||||
@apply flex items-center justify-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,48 @@
|
||||
<script>
|
||||
<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';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
macro: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
resolvedMacro() {
|
||||
return this.macro.actions.map(action => {
|
||||
return {
|
||||
actionName: resolveActionName(action.action_name),
|
||||
actionValue: this.getActionValue(
|
||||
action.action_name,
|
||||
action.action_params
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
...mapGetters({
|
||||
labels: 'labels/getLabels',
|
||||
teams: 'teams/getTeams',
|
||||
agents: 'agents/getAgents',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
getActionValue(key, params) {
|
||||
const actionsMap = {
|
||||
assign_team: resolveTeamIds(this.teams, params),
|
||||
add_label: resolveLabels(this.labels, params),
|
||||
remove_label: resolveLabels(this.labels, params),
|
||||
assign_agent: resolveAgents(this.agents, 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 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>
|
||||
@@ -68,13 +59,13 @@ export default {
|
||||
>
|
||||
<div
|
||||
v-if="i !== macro.actions.length - 1"
|
||||
class="top-[0.390625rem] absolute -bottom-1 left-0 w-px bg-slate-75 dark:bg-slate-600"
|
||||
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-slate-600"
|
||||
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">
|
||||
{{ action.actionName }}
|
||||
{{ $t(`MACROS.ACTIONS.${action.actionName}`) }}
|
||||
</p>
|
||||
<p class="text-n-slate-12 text-sm">{{ action.actionValue }}</p>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,14 @@ 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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
Avatar,
|
||||
},
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
@@ -270,18 +276,19 @@ export default {
|
||||
class="w-full px-8 pt-6 pb-8 contact--form"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<woot-avatar-uploader
|
||||
:label="$t('CONTACT_FORM.FORM.AVATAR.LABEL')"
|
||||
:src="avatarUrl"
|
||||
:username-avatar="name"
|
||||
:delete-avatar="!!avatarUrl"
|
||||
class="settings-item"
|
||||
@on-avatar-select="handleImageUpload"
|
||||
@on-avatar-delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
@@ -342,7 +349,7 @@ export default {
|
||||
</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-yellow-500 text-yellow-700 dark:border-yellow-700 bg-yellow-200/60 dark:bg-yellow-200/20 dark:text-yellow-400"
|
||||
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>
|
||||
@@ -390,27 +397,30 @@ export default {
|
||||
class="flex items-stretch w-full mb-4"
|
||||
>
|
||||
<span
|
||||
class="flex items-center h-10 px-2 text-sm border-solid bg-slate-50 border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md dark:bg-slate-700 text-slate-800 dark:text-slate-100 border-slate-200 dark:border-slate-600"
|
||||
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"
|
||||
class="input-group-field ltr:!rounded-l-none rtl:!rounded-r-none !mb-0"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<div class="w-full">
|
||||
<woot-submit-button
|
||||
:loading="inProgress"
|
||||
:button-text="$t('CONTACT_FORM.FORM.SUBMIT')"
|
||||
/>
|
||||
<button class="button clear" @click.prevent="onCancel">
|
||||
{{ $t('CONTACT_FORM.FORM.CANCEL') }}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import ContactInfoRow from './ContactInfoRow.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SocialIcons from './SocialIcons.vue';
|
||||
import EditContact from './EditContact.vue';
|
||||
import NewConversation from './NewConversation.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';
|
||||
|
||||
@@ -24,9 +24,9 @@ export default {
|
||||
NextButton,
|
||||
ContactInfoRow,
|
||||
EditContact,
|
||||
Thumbnail,
|
||||
Avatar,
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
NewConversation,
|
||||
ContactMergeModal,
|
||||
},
|
||||
props: {
|
||||
@@ -49,7 +49,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showConversationModal: false,
|
||||
showMergeModal: false,
|
||||
showDeleteModal: false,
|
||||
};
|
||||
@@ -92,17 +91,29 @@ export default {
|
||||
return ` ${this.contact.name}?`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'contact.id': {
|
||||
handler(id) {
|
||||
this.$store.dispatch('contacts/fetchContactableInbox', id);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
dynamicTime,
|
||||
toggleEditModal() {
|
||||
this.showEditModal = !this.showEditModal;
|
||||
},
|
||||
toggleConversationModal() {
|
||||
this.showConversationModal = !this.showConversationModal;
|
||||
emitter.emit(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
this.showConversationModal
|
||||
);
|
||||
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;
|
||||
@@ -113,7 +124,6 @@ export default {
|
||||
},
|
||||
closeDelete() {
|
||||
this.showDeleteModal = false;
|
||||
this.showConversationModal = false;
|
||||
this.showEditModal = false;
|
||||
},
|
||||
findCountryFlag(countryCode, cityAndCountry) {
|
||||
@@ -169,12 +179,14 @@ export default {
|
||||
<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">
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="showAvatar"
|
||||
:src="contact.thumbnail"
|
||||
size="48px"
|
||||
:username="contact.name"
|
||||
:name="contact.name"
|
||||
:status="contact.availability_status"
|
||||
:size="48"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -250,14 +262,22 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full mt-0.5 gap-2">
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="toggleConversationModal"
|
||||
/>
|
||||
<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>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('EDIT_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-pencil-simple"
|
||||
@@ -293,12 +313,6 @@ export default {
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<NewConversation
|
||||
v-if="contact.id"
|
||||
:show="showConversationModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleConversationModal"
|
||||
/>
|
||||
<ContactMergeModal
|
||||
v-if="showMergeModal"
|
||||
:primary-contact="contact"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
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: {
|
||||
@@ -62,15 +64,13 @@ export default {
|
||||
<span v-else class="text-sm text-n-slate-11">
|
||||
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
|
||||
</span>
|
||||
|
||||
<woot-button
|
||||
<NextButton
|
||||
v-if="showCopy"
|
||||
type="submit"
|
||||
variant="clear"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="clipboard"
|
||||
class-names="p-0"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="ltr:-ml-1 rtl:-mr-1"
|
||||
icon="i-lucide-clipboard"
|
||||
@click="onCopy"
|
||||
/>
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
|
||||
import Spinner from 'next/spinner/Spinner.vue';
|
||||
|
||||
const { contactId } = defineProps({
|
||||
contactId: { type: String, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const uiFlags = useMapGetter('contactNotes/getUIFlags');
|
||||
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
||||
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const notes = computed(() => notGetterFn.value(contactId));
|
||||
|
||||
const getWrittenBy = ({ user } = {}) => {
|
||||
const currentUserId = currentUser.value?.id;
|
||||
return user?.id === currentUserId
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: user?.name || t('CONVERSATION.BOT');
|
||||
};
|
||||
|
||||
watch(
|
||||
() => contactId,
|
||||
() => store.dispatch('contactNotes/get', { contactId }),
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
|
||||
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
|
||||
</div>
|
||||
<div v-else class="max-h-[300px] overflow-scroll">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="p-4 last-of-type:border-b-0"
|
||||
:note="note"
|
||||
collapsible
|
||||
:written-by="getWrittenBy(note)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,611 +0,0 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
// constants & helpers
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { getInboxSource, INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
// store
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
// composables
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
// mixins
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
|
||||
// components
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
|
||||
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue';
|
||||
import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert.vue';
|
||||
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import WhatsappTemplates from './WhatsappTemplates.vue';
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
WootMessageEditor,
|
||||
ReplyEmailHead,
|
||||
CannedResponse,
|
||||
WhatsappTemplates,
|
||||
InboxDropdownItem,
|
||||
FileUpload,
|
||||
AttachmentPreview,
|
||||
MessageSignatureMissingAlert,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin],
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
onSubmit: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'success'],
|
||||
setup() {
|
||||
const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } =
|
||||
useUISettings();
|
||||
const v$ = useVuelidate();
|
||||
const uploadAttachment = ref(false);
|
||||
|
||||
return {
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setSignatureFlagForInbox,
|
||||
v$,
|
||||
uploadAttachment,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
showCannedResponseMenu: false,
|
||||
cannedResponseSearchKey: '',
|
||||
bccEmails: '',
|
||||
ccEmails: '',
|
||||
targetInbox: {},
|
||||
whatsappTemplateSelected: false,
|
||||
attachedFiles: [],
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
return {
|
||||
subject: {
|
||||
required: requiredIf(this.isAnEmailInbox),
|
||||
},
|
||||
message: {
|
||||
required,
|
||||
},
|
||||
targetInbox: {
|
||||
required,
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
conversationsUiFlags: 'contactConversations/getUIFlags',
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
messageSignature: 'getMessageSignature',
|
||||
}),
|
||||
sendWithSignature() {
|
||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
||||
},
|
||||
signatureToApply() {
|
||||
return this.messageSignature;
|
||||
},
|
||||
newMessagePayload() {
|
||||
const payload = {
|
||||
inboxId: this.targetInbox.id,
|
||||
sourceId: this.targetInbox.sourceId,
|
||||
contactId: this.contact.id,
|
||||
message: { content: this.message },
|
||||
mailSubject: this.subject,
|
||||
assigneeId: this.currentUser.id,
|
||||
};
|
||||
|
||||
if (this.attachedFiles && this.attachedFiles.length) {
|
||||
payload.files = [];
|
||||
this.setAttachmentPayload(payload);
|
||||
}
|
||||
|
||||
if (this.ccEmails) {
|
||||
payload.message.cc_emails = this.ccEmails;
|
||||
}
|
||||
|
||||
if (this.bccEmails) {
|
||||
payload.message.bcc_emails = this.bccEmails;
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
selectedInbox: {
|
||||
get() {
|
||||
const inboxList = this.contact.contact_inboxes || [];
|
||||
return (
|
||||
inboxList.find(inbox => {
|
||||
return inbox.inbox?.id && inbox.inbox?.id === this.targetInbox?.id;
|
||||
}) || {
|
||||
inbox: {},
|
||||
}
|
||||
);
|
||||
},
|
||||
set(value) {
|
||||
this.targetInbox = value.inbox;
|
||||
},
|
||||
},
|
||||
showNoInboxAlert() {
|
||||
if (!this.contact.contact_inboxes) {
|
||||
return false;
|
||||
}
|
||||
return this.inboxes.length === 0 && !this.uiFlags.isFetchingInboxes;
|
||||
},
|
||||
isSignatureEnabledForInbox() {
|
||||
return this.isAnEmailInbox && this.sendWithSignature;
|
||||
},
|
||||
signatureToggleTooltip() {
|
||||
return this.sendWithSignature
|
||||
? this.$t('CONVERSATION.FOOTER.DISABLE_SIGN_TOOLTIP')
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
inboxes() {
|
||||
const inboxList = this.contact.contact_inboxes || [];
|
||||
return inboxList.map(inbox => ({
|
||||
...inbox.inbox,
|
||||
sourceId: inbox.source_id,
|
||||
}));
|
||||
},
|
||||
isAnEmailInbox() {
|
||||
return (
|
||||
this.selectedInbox &&
|
||||
this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL
|
||||
);
|
||||
},
|
||||
isAnWebWidgetInbox() {
|
||||
return (
|
||||
this.selectedInbox &&
|
||||
this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB
|
||||
);
|
||||
},
|
||||
isEmailOrWebWidgetInbox() {
|
||||
return this.isAnEmailInbox || this.isAnWebWidgetInbox;
|
||||
},
|
||||
hasWhatsappTemplates() {
|
||||
return !!this.selectedInbox.inbox?.message_templates;
|
||||
},
|
||||
hasAttachments() {
|
||||
return this.attachedFiles.length;
|
||||
},
|
||||
inbox() {
|
||||
return this.targetInbox;
|
||||
},
|
||||
allowedFileTypes() {
|
||||
return ALLOWED_FILE_TYPES;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
message(value) {
|
||||
this.hasSlashCommand = value[0] === '/' && !this.isEmailOrWebWidgetInbox;
|
||||
const hasNextWord = value.includes(' ');
|
||||
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
|
||||
if (isShortCodeActive) {
|
||||
this.cannedResponseSearchKey = value.substring(1);
|
||||
this.showCannedResponseMenu = true;
|
||||
} else {
|
||||
this.cannedResponseSearchKey = '';
|
||||
this.showCannedResponseMenu = false;
|
||||
}
|
||||
},
|
||||
targetInbox() {
|
||||
this.setSignature();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setSignature();
|
||||
},
|
||||
methods: {
|
||||
setSignature() {
|
||||
if (this.messageSignature) {
|
||||
if (this.isSignatureEnabledForInbox) {
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
} else {
|
||||
this.message = removeSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
}
|
||||
},
|
||||
setAttachmentPayload(payload) {
|
||||
this.attachedFiles.forEach(attachment => {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
payload.files.push(attachment.blobSignedId);
|
||||
} else {
|
||||
payload.files.push(attachment.resource.file);
|
||||
}
|
||||
});
|
||||
},
|
||||
attachFile({ blob, file }) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
reader.onloadend = () => {
|
||||
this.attachedFiles.push({
|
||||
currentChatId: this.contact.id,
|
||||
resource: blob || file,
|
||||
isPrivate: this.isPrivate,
|
||||
thumb: reader.result,
|
||||
blobSignedId: blob ? blob.signed_id : undefined,
|
||||
});
|
||||
};
|
||||
},
|
||||
removeAttachment(attachments) {
|
||||
this.attachedFiles = attachments;
|
||||
},
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('success');
|
||||
},
|
||||
replaceTextWithCannedResponse(message) {
|
||||
this.message = message;
|
||||
},
|
||||
toggleCannedMenu(value) {
|
||||
this.showCannedMenu = value;
|
||||
},
|
||||
prepareWhatsAppMessagePayload({ message: content, templateParams }) {
|
||||
const payload = {
|
||||
inboxId: this.targetInbox.id,
|
||||
sourceId: this.targetInbox.sourceId,
|
||||
contactId: this.contact.id,
|
||||
message: { content, template_params: templateParams },
|
||||
assigneeId: this.currentUser.id,
|
||||
};
|
||||
return payload;
|
||||
},
|
||||
onFormSubmit() {
|
||||
const isFromWhatsApp = false;
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.createConversation({
|
||||
payload: this.newMessagePayload,
|
||||
isFromWhatsApp,
|
||||
});
|
||||
},
|
||||
async createConversation({ payload, isFromWhatsApp }) {
|
||||
try {
|
||||
const data = await this.onSubmit(payload, isFromWhatsApp);
|
||||
const action = {
|
||||
type: 'link',
|
||||
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||
message: this.$t('NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||
};
|
||||
this.onSuccess();
|
||||
useAlert(this.$t('NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||
} catch (error) {
|
||||
if (error instanceof ExceptionWithMessage) {
|
||||
useAlert(error.data);
|
||||
} else {
|
||||
useAlert(this.$t('NEW_CONVERSATION.FORM.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
toggleWaTemplate(val) {
|
||||
this.whatsappTemplateSelected = val;
|
||||
},
|
||||
async onSendWhatsAppReply(messagePayload) {
|
||||
const isFromWhatsApp = true;
|
||||
const payload = this.prepareWhatsAppMessagePayload(messagePayload);
|
||||
await this.createConversation({ payload, isFromWhatsApp });
|
||||
},
|
||||
inboxReadableIdentifier(inbox) {
|
||||
return `${inbox.name} (${inbox.channel_type})`;
|
||||
},
|
||||
computedInboxSource(inbox) {
|
||||
if (!inbox.channel_type) return '';
|
||||
const classByType = getInboxSource(
|
||||
inbox.channel_type,
|
||||
inbox.phone_number,
|
||||
inbox
|
||||
);
|
||||
return classByType;
|
||||
},
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
this.setSignature();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/prefer-true-attribute-shorthand -->
|
||||
<template>
|
||||
<form class="w-full conversation--form" @submit.prevent="onFormSubmit">
|
||||
<div
|
||||
v-if="showNoInboxAlert"
|
||||
class="relative mx-0 mt-0 mb-2.5 p-2 rounded-none text-sm border border-solid border-yellow-500 dark:border-yellow-700 bg-yellow-200/60 dark:bg-yellow-200/20 text-slate-700 dark:text-yellow-400"
|
||||
>
|
||||
<p class="mb-0">
|
||||
{{ $t('NEW_CONVERSATION.NO_INBOX') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div class="w-[50%]">
|
||||
<label>
|
||||
{{ $t('NEW_CONVERSATION.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="multiselect-wrap--small"
|
||||
:class="{ 'has-multi-select-error': v$.targetInbox.$error }"
|
||||
>
|
||||
<multiselect
|
||||
v-model="targetInbox"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
|
||||
selected-label=""
|
||||
select-label=""
|
||||
class="reset-base"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
close-on-select
|
||||
:options="[...inboxes]"
|
||||
>
|
||||
<template #singleLabel="{ option }">
|
||||
<InboxDropdownItem
|
||||
v-if="option.name"
|
||||
:name="option.name"
|
||||
:inbox-identifier="computedInboxSource(option)"
|
||||
:channel-type="option.channel_type"
|
||||
/>
|
||||
<span v-else>
|
||||
{{ $t('NEW_CONVERSATION.FORM.INBOX.PLACEHOLDER') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<InboxDropdownItem
|
||||
:name="option.name"
|
||||
:inbox-identifier="computedInboxSource(option)"
|
||||
:channel-type="option.channel_type"
|
||||
/>
|
||||
</template>
|
||||
</multiselect>
|
||||
</div>
|
||||
<label :class="{ error: v$.targetInbox.$error }">
|
||||
<span v-if="v$.targetInbox.$error" class="message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.INBOX.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="w-[50%]">
|
||||
<label>
|
||||
{{ $t('NEW_CONVERSATION.FORM.TO.LABEL') }}
|
||||
<div
|
||||
class="flex items-center h-[2.4735rem] rounded-sm py-1 px-2 bg-slate-25 dark:bg-slate-900 border border-solid border-slate-75 dark:border-slate-600"
|
||||
>
|
||||
<Thumbnail
|
||||
:src="contact.thumbnail"
|
||||
size="24px"
|
||||
:username="contact.name"
|
||||
:status="contact.availability_status"
|
||||
/>
|
||||
<h4
|
||||
class="m-0 ml-2 mr-2 text-sm text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
{{ contact.name }}
|
||||
</h4>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAnEmailInbox" class="w-full">
|
||||
<div class="w-full">
|
||||
<label :class="{ error: v$.subject.$error }">
|
||||
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.LABEL') }}
|
||||
<input
|
||||
v-model="subject"
|
||||
type="text"
|
||||
:placeholder="$t('NEW_CONVERSATION.FORM.SUBJECT.PLACEHOLDER')"
|
||||
@input="v$.subject.$touch"
|
||||
/>
|
||||
<span v-if="v$.subject.$error" class="message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.SUBJECT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="w-full">
|
||||
<div class="relative">
|
||||
<CannedResponse
|
||||
v-if="showCannedResponseMenu && hasSlashCommand"
|
||||
:search-key="cannedResponseSearchKey"
|
||||
@replace="replaceTextWithCannedResponse"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isEmailOrWebWidgetInbox">
|
||||
<label>
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
|
||||
</label>
|
||||
<ReplyEmailHead
|
||||
v-if="isAnEmailInbox"
|
||||
v-model:cc-emails="ccEmails"
|
||||
v-model:bcc-emails="bccEmails"
|
||||
/>
|
||||
<div class="editor-wrap">
|
||||
<WootMessageEditor
|
||||
v-model="message"
|
||||
class="message-editor"
|
||||
:class="{ editor_warning: v$.message.$error }"
|
||||
enable-variables
|
||||
:signature="signatureToApply"
|
||||
allow-signature
|
||||
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@toggle-canned-menu="toggleCannedMenu"
|
||||
@blur="v$.message.$touch"
|
||||
>
|
||||
<template #footer>
|
||||
<MessageSignatureMissingAlert
|
||||
v-if="isSignatureEnabledForInbox && !messageSignature"
|
||||
class="!mx-0 mb-1"
|
||||
/>
|
||||
<div v-if="isAnEmailInbox" class="mt-px mb-3">
|
||||
<woot-button
|
||||
v-tooltip.top-end="signatureToggleTooltip"
|
||||
icon="signature"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="signatureToggleTooltip"
|
||||
@click.prevent="toggleMessageSignature"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</WootMessageEditor>
|
||||
<span v-if="v$.message.$error" class="editor-warning__message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<WhatsappTemplates
|
||||
v-else-if="hasWhatsappTemplates"
|
||||
:inbox-id="selectedInbox.inbox.id"
|
||||
@on-select-template="toggleWaTemplate"
|
||||
@on-send="onSendWhatsAppReply"
|
||||
/>
|
||||
<label v-else :class="{ error: v$.message.$error }">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
|
||||
<textarea
|
||||
v-model="message"
|
||||
class="min-h-[5rem]"
|
||||
type="text"
|
||||
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
|
||||
@input="v$.message.$touch"
|
||||
/>
|
||||
<span v-if="v$.message.$error" class="message">
|
||||
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="isEmailOrWebWidgetInbox" class="flex flex-col">
|
||||
<FileUpload
|
||||
ref="uploadAttachment"
|
||||
input-id="newConversationAttachment"
|
||||
:size="4096 * 4096"
|
||||
:accept="allowedFileTypes"
|
||||
multiple
|
||||
:drop="true"
|
||||
:drop-directory="false"
|
||||
:data="{
|
||||
direct_upload_url: '/rails/active_storage/direct_uploads',
|
||||
direct_upload: true,
|
||||
}"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<woot-button
|
||||
class-names="button--upload"
|
||||
icon="attach"
|
||||
emoji="📎"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
>
|
||||
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.SELECT') }}
|
||||
</woot-button>
|
||||
<span
|
||||
class="text-xs font-medium text-slate-500 ltr:ml-1 rtl:mr-1 dark:text-slate-400"
|
||||
>
|
||||
{{ $t('NEW_CONVERSATION.FORM.ATTACHMENTS.HELP_TEXT') }}
|
||||
</span>
|
||||
</FileUpload>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="max-h-20 overflow-y-auto mb-4 mt-1.5"
|
||||
>
|
||||
<AttachmentPreview
|
||||
class="[&>.preview-item]:dark:bg-slate-700 flex-row flex-wrap gap-x-3 gap-y-1"
|
||||
:attachments="attachedFiles"
|
||||
@remove-attachment="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasWhatsappTemplates"
|
||||
class="flex flex-row justify-end w-full gap-2 px-0 py-2"
|
||||
>
|
||||
<button class="button clear" @click.prevent="onCancel">
|
||||
{{ $t('NEW_CONVERSATION.FORM.CANCEL') }}
|
||||
</button>
|
||||
<woot-button type="submit" :is-loading="conversationsUiFlags.isCreating">
|
||||
{{ $t('NEW_CONVERSATION.FORM.SUBMIT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
|
||||
<transition v-if="isEmailOrWebWidgetInbox" name="modal-fade">
|
||||
<div
|
||||
v-show="uploadAttachment && uploadAttachment.dropActive"
|
||||
class="absolute top-0 bottom-0 left-0 right-0 z-30 flex flex-col items-center justify-center w-full h-full gap-2 bg-white/80 dark:bg-slate-700/80"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-slate-600 dark:text-slate-200">
|
||||
{{ $t('CONVERSATION.REPLYBOX.DRAG_DROP') }}
|
||||
</h4>
|
||||
</div>
|
||||
</transition>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversation--form {
|
||||
@apply pt-4 px-8 pb-8;
|
||||
}
|
||||
|
||||
.message-editor {
|
||||
@apply px-3;
|
||||
|
||||
::v-deep {
|
||||
.ProseMirror-menubar {
|
||||
@apply rounded-tl-[4px];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-uploads {
|
||||
@apply text-start;
|
||||
}
|
||||
|
||||
.multiselect-wrap--small.has-multi-select-error {
|
||||
::v-deep {
|
||||
.multiselect__tags {
|
||||
@apply border-red-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.mention--box {
|
||||
@apply left-0 m-auto right-0 top-auto h-fit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
<script>
|
||||
import ConversationForm from './ConversationForm.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationForm,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'update:show'],
|
||||
computed: {
|
||||
localShow: {
|
||||
get() {
|
||||
return this.show;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:show', value);
|
||||
},
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'contact.id'(id) {
|
||||
this.$store.dispatch('contacts/fetchContactableInbox', id);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { id } = this.contact;
|
||||
this.$store.dispatch('contacts/fetchContactableInbox', id);
|
||||
},
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
async onSubmit(params, isFromWhatsApp) {
|
||||
const data = await this.$store.dispatch('contactConversations/create', {
|
||||
params,
|
||||
isFromWhatsApp,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onCancel">
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="$t('NEW_CONVERSATION.TITLE')"
|
||||
:header-content="$t('NEW_CONVERSATION.DESC')"
|
||||
/>
|
||||
<ConversationForm
|
||||
:contact="contact"
|
||||
:on-submit="onSubmit"
|
||||
@success="onSuccess"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
@@ -41,7 +41,7 @@ export default {
|
||||
<fluent-icon
|
||||
:icon="`brand-${profile.key}`"
|
||||
size="16"
|
||||
class="text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||
class="text-n-slate-11 hover:text-n-slate-10"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<script>
|
||||
import TemplatesPicker from 'dashboard/components/widgets/conversation/WhatsappTemplates/TemplatesPicker.vue';
|
||||
import TemplateParser from 'dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
TemplateParser,
|
||||
},
|
||||
props: {
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['pickTemplate', 'onSend', 'cancel'],
|
||||
data() {
|
||||
return {
|
||||
selectedWaTemplate: null,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
pickTemplate(template) {
|
||||
this.$emit('pickTemplate', true);
|
||||
this.selectedWaTemplate = template;
|
||||
},
|
||||
onResetTemplate() {
|
||||
this.$emit('pickTemplate', false);
|
||||
this.selectedWaTemplate = null;
|
||||
},
|
||||
onSendMessage(message) {
|
||||
this.$emit('onSend', message);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap mx-0">
|
||||
<TemplatesPicker
|
||||
v-if="!selectedWaTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+14
-18
@@ -9,6 +9,7 @@ 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: {
|
||||
@@ -79,17 +80,13 @@ const filteredCustomAttributes = computed(() =>
|
||||
customAttributes.value,
|
||||
attribute.attribute_key
|
||||
);
|
||||
const isCheckbox = attribute.attribute_display_type === 'checkbox';
|
||||
const defaultValue = isCheckbox ? false : '';
|
||||
|
||||
return {
|
||||
...attribute,
|
||||
type: 'custom_attribute',
|
||||
key: attribute.attribute_key,
|
||||
// Set value from customAttributes if it exists, otherwise use default value
|
||||
value: hasValue
|
||||
? customAttributes.value[attribute.attribute_key]
|
||||
: defaultValue,
|
||||
// Set value from customAttributes if it exists, otherwise use ''
|
||||
value: hasValue ? customAttributes.value[attribute.attribute_key] : '',
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -214,7 +211,7 @@ const onUpdate = async (key, value) => {
|
||||
} else {
|
||||
store.dispatch('contacts/update', {
|
||||
id: props.contactId,
|
||||
custom_attributes: updatedAttributes,
|
||||
customAttributes: updatedAttributes,
|
||||
});
|
||||
}
|
||||
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
|
||||
@@ -271,7 +268,7 @@ const evenClass = [
|
||||
ghost-class="ghost"
|
||||
handle=".drag-handle"
|
||||
item-key="key"
|
||||
class="last:rounded-b-lg overflow-hidden"
|
||||
class="last:rounded-b-lg"
|
||||
:class="evenClass"
|
||||
@start="dragging = true"
|
||||
@end="onDragEnd"
|
||||
@@ -318,17 +315,16 @@ const evenClass = [
|
||||
{{ 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 px-2 py-2">
|
||||
<woot-button
|
||||
size="small"
|
||||
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
|
||||
variant="clear"
|
||||
color-scheme="primary"
|
||||
class="!px-2 hover:!bg-transparent dark:hover:!bg-transparent"
|
||||
<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"
|
||||
>
|
||||
{{ toggleButtonText }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -102,20 +102,21 @@ export default {
|
||||
@remove="removeLabelFromConversation"
|
||||
/>
|
||||
|
||||
<div class="dropdown-wrap">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
|
||||
class="dropdown-pane"
|
||||
>
|
||||
<LabelDropdown
|
||||
v-if="showSearchDropdownLabel"
|
||||
:account-labels="accountLabels"
|
||||
:selected-labels="savedLabels"
|
||||
:allow-creation="isAdmin"
|
||||
@add="addLabelToConversation"
|
||||
@remove="removeLabelFromConversation"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
@@ -131,28 +132,8 @@ export default {
|
||||
width: 100%;
|
||||
|
||||
.label-wrap {
|
||||
line-height: var(--space-medium);
|
||||
line-height: 1.5rem;
|
||||
position: relative;
|
||||
|
||||
.dropdown-wrap {
|
||||
display: flex;
|
||||
left: -1px;
|
||||
margin-right: var(--space-medium);
|
||||
position: absolute;
|
||||
top: var(--space-medium);
|
||||
width: 100%;
|
||||
|
||||
.dropdown-pane {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--r-500);
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import SwitchLayout from './SwitchLayout.vue';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
export default {
|
||||
components: {
|
||||
SwitchLayout,
|
||||
},
|
||||
directives: {
|
||||
focus: {
|
||||
inserted(el) {
|
||||
el.focus();
|
||||
},
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['toggleConversationLayout'],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
searchUrl() {
|
||||
return frontendURL(`accounts/${this.accountId}/search`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex px-4 pb-1 justify-between items-center flex-row gap-1 pt-2.5 border-b border-transparent"
|
||||
>
|
||||
<woot-sidemenu-icon
|
||||
size="tiny"
|
||||
class="relative top-0 ltr:-ml-1.5 rtl:-mr-1.5 flex-shrink-0 focus:!bg-n-solid-3 dark:!hover:bg-n-solid-2 hover:!bg-n-alpha-2"
|
||||
/>
|
||||
<router-link
|
||||
:to="searchUrl"
|
||||
class="inline-flex items-center flex-1 h-6 min-w-0 gap-1 px-2 py-0 text-left rounded-md rtl:mr-2.5 search-link rtl:text-right bg-n-slate-9/10 hover:bg-n-slate-3"
|
||||
>
|
||||
<div class="flex flex-shrink-0">
|
||||
<fluent-icon
|
||||
icon="search"
|
||||
class="search--icon text-n-slate-11"
|
||||
size="16"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="mb-0 overflow-hidden text-sm search--label whitespace-nowrap text-ellipsis text-n-slate-11"
|
||||
>
|
||||
{{ $t('CONVERSATION.SEARCH_MESSAGES') }}
|
||||
</p>
|
||||
</router-link>
|
||||
<SwitchLayout
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@toggle="$emit('toggleConversationLayout')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-link {
|
||||
&:hover {
|
||||
.search--icon,
|
||||
.search--label {
|
||||
@apply hover:text-woot-500 dark:hover:text-woot-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -31,7 +31,7 @@ export default {
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0"
|
||||
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
filterType: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
customViewsQuery: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
openLastSavedItem: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
name: '',
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isButtonDisabled() {
|
||||
return this.v$.name.$invalid;
|
||||
},
|
||||
},
|
||||
|
||||
validations: {
|
||||
name: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
async saveCustomViews() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.$store.dispatch('customViews/create', {
|
||||
name: this.name,
|
||||
filter_type: this.filterType,
|
||||
query: this.customViewsQuery,
|
||||
});
|
||||
this.alertMessage =
|
||||
this.filterType === 0
|
||||
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.SUCCESS_MESSAGE')
|
||||
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.SUCCESS_MESSAGE');
|
||||
this.onClose();
|
||||
|
||||
useTrack(CONTACTS_EVENTS.SAVE_FILTER, {
|
||||
type: this.filterType === 0 ? 'folder' : 'segment',
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error?.message;
|
||||
this.alertMessage =
|
||||
errorMessage || this.filterType === 0
|
||||
? errorMessage
|
||||
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
|
||||
} finally {
|
||||
useAlert(this.alertMessage);
|
||||
}
|
||||
this.openLastSavedItem();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="show" :on-close="onClose">
|
||||
<woot-modal-header :header-title="$t('FILTER.CUSTOM_VIEWS.ADD.TITLE')" />
|
||||
<form class="w-full" @submit.prevent="saveCustomViews">
|
||||
<div class="w-full">
|
||||
<woot-input
|
||||
v-model="name"
|
||||
:label="$t('FILTER.CUSTOM_VIEWS.ADD.LABEL')"
|
||||
type="text"
|
||||
:error="
|
||||
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
|
||||
"
|
||||
:class="{ error: v$.name.$error }"
|
||||
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
|
||||
@blur="v$.name.$touch"
|
||||
/>
|
||||
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-button :disabled="isButtonDisabled">
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON') }}
|
||||
</woot-button>
|
||||
<woot-button variant="clear" @click.prevent="onClose">
|
||||
{{ $t('FILTER.CUSTOM_VIEWS.ADD.CANCEL_BUTTON') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</woot-modal>
|
||||
</template>
|
||||
+57
-55
@@ -1,88 +1,90 @@
|
||||
<script>
|
||||
<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';
|
||||
|
||||
export default {
|
||||
name: 'ArticleSearchResultItem',
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Untitled',
|
||||
},
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
locale: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
emits: ['insert', 'preview'],
|
||||
methods: {
|
||||
handleInsert(e) {
|
||||
e.stopPropagation();
|
||||
this.$emit('insert', this.id);
|
||||
},
|
||||
handlePreview(e) {
|
||||
e.stopPropagation();
|
||||
this.$emit('preview', this.id);
|
||||
},
|
||||
async handleCopy(e) {
|
||||
e.stopPropagation();
|
||||
await copyTextToClipboard(this.url);
|
||||
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
|
||||
},
|
||||
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 bg-white border border-transparent border-solid rounded-md cursor-pointer dark:bg-slate-900 hover:bg-slate-25 hover:dark:bg-slate-800 group focus:outline-none focus:bg-slate-25 focus:border-slate-500 dark:focus:border-slate-400 dark:focus:bg-slate-800"
|
||||
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-slate-900 dark:text-slate-25 hover:underline group-hover:underline"
|
||||
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-slate-600 dark:text-slate-300"
|
||||
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">
|
||||
<woot-button
|
||||
<Button
|
||||
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.COPY_LINK')"
|
||||
variant="hollow"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
icon="copy"
|
||||
faded
|
||||
slate
|
||||
xs
|
||||
type="reset"
|
||||
icon="i-lucide-copy"
|
||||
class="invisible group-hover:visible"
|
||||
@click="handleCopy"
|
||||
/>
|
||||
<woot-button
|
||||
class="insert-button"
|
||||
variant="smooth"
|
||||
color-scheme="secondary"
|
||||
size="tiny"
|
||||
<Button
|
||||
xs
|
||||
faded
|
||||
slate
|
||||
:label="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE')"
|
||||
@click="handleInsert"
|
||||
>
|
||||
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
+42
-46
@@ -1,68 +1,64 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import IframeLoader from 'shared/components/IframeLoader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
export default {
|
||||
name: 'ArticleView',
|
||||
components: {
|
||||
IframeLoader,
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['back', 'insert'],
|
||||
methods: {
|
||||
onBack(e) {
|
||||
e.stopPropagation();
|
||||
this.$emit('back');
|
||||
},
|
||||
onInsert(e) {
|
||||
e.stopPropagation();
|
||||
this.$emit('insert');
|
||||
},
|
||||
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">
|
||||
<woot-button
|
||||
variant="link"
|
||||
size="small"
|
||||
icon="chevron-left"
|
||||
<Button
|
||||
link
|
||||
xs
|
||||
:label="$t('HELP_CENTER.ARTICLE_SEARCH.BACK_RESULTS')"
|
||||
icon="i-lucide-chevron-left"
|
||||
@click="onBack"
|
||||
>
|
||||
{{ $t('HELP_CENTER.ARTICLE_SEARCH.BACK_RESULTS') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
<div class="-ml-4 h-full overflow-y-auto">
|
||||
<div class="w-full h-full min-h-0">
|
||||
<IframeLoader :url="url" />
|
||||
<IframeLoader :url="url" :is-rtl="isRTL" is-dir-applied />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 py-2">
|
||||
<woot-button
|
||||
variant="hollow"
|
||||
size="small"
|
||||
is-expanded
|
||||
color-scheme="secondary"
|
||||
icon="chevron-left"
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
sm
|
||||
type="reset"
|
||||
icon="i-lucide-chevron-left"
|
||||
:label="$t('HELP_CENTER.ARTICLE_SEARCH.BACK')"
|
||||
@click="onBack"
|
||||
>
|
||||
{{ $t('HELP_CENTER.ARTICLE_SEARCH.BACK') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
is-expanded
|
||||
icon="arrow-download"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
type="submit"
|
||||
icon="i-lucide-arrow-down-to-dot"
|
||||
:label="$t('HELP_CENTER.ARTICLE_SEARCH.INSERT_ARTICLE')"
|
||||
@click="onInsert"
|
||||
>
|
||||
{{ $t('HELP_CENTER.ARTICLE_SEARCH.INSERT_ARTICLE') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+4
-9
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
@@ -46,16 +47,10 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<div class="flex flex-col py-1">
|
||||
<div class="flex items-center justify-between py-2 mb-1">
|
||||
<h3 class="text-base text-slate-900 dark:text-slate-25">
|
||||
<h3 class="text-base text-n-slate-12">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="dismiss"
|
||||
@click="onClose"
|
||||
/>
|
||||
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
@@ -68,7 +63,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
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-slate-700 shadow-sm ring-2 ring-transparent ring-slate-300 border border-solid placeholder:text-slate-400 focus:border-woot-600 focus:ring-woot-200 !mb-0 focus:bg-slate-25 dark:focus:bg-slate-700 dark:focus:ring-woot-700"
|
||||
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"
|
||||
/>
|
||||
|
||||
+14
-3
@@ -1,6 +1,7 @@
|
||||
<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';
|
||||
@@ -33,6 +34,15 @@ export default {
|
||||
};
|
||||
},
|
||||
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 '';
|
||||
@@ -47,6 +57,7 @@ export default {
|
||||
|
||||
return `${url}`;
|
||||
},
|
||||
|
||||
searchResultsWithUrl() {
|
||||
return this.searchResults.map(article => ({
|
||||
...article,
|
||||
@@ -65,7 +76,8 @@ export default {
|
||||
this.selectedPortalSlug,
|
||||
'',
|
||||
'',
|
||||
article.slug
|
||||
article.slug,
|
||||
this.portalCustomDomain
|
||||
);
|
||||
},
|
||||
localeName(code) {
|
||||
@@ -111,7 +123,6 @@ export default {
|
||||
},
|
||||
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();
|
||||
@@ -126,7 +137,7 @@ export default {
|
||||
>
|
||||
<div
|
||||
v-on-clickaway="onClose"
|
||||
class="flex flex-col px-4 pb-4 rounded-md shadow-md border border-solid border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-900 z-[1000] max-w-[720px] md:w-[20rem] lg:w-[24rem] xl:w-[28rem] 2xl:w-[32rem] h-[calc(100vh-20rem)] max-h-[40rem]"
|
||||
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')"
|
||||
|
||||
+9
-9
@@ -39,13 +39,19 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-end h-full gap-1 py-4 overflow-y-auto bg-white dark:bg-slate-900"
|
||||
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="empty-state-message">
|
||||
<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="empty-state-message">
|
||||
<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>
|
||||
@@ -65,9 +71,3 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state-message {
|
||||
@apply text-center flex items-center justify-center px-4 py-8 text-slate-500 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<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,
|
||||
@@ -63,17 +68,17 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-12 sm:gap-16 items-center justify-center py-0 px-4 md:px-0 w-full min-h-screen max-w-full overflow-auto bg-white dark:bg-slate-900"
|
||||
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-background"
|
||||
>
|
||||
<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-slate-900 dark:text-white text-left sm:text-center text-4xl sm:text-5xl mb-6 font-semibold"
|
||||
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-slate-700 dark:text-slate-200"
|
||||
class="max-w-2xl text-base font-normal leading-6 text-left sm:text-center text-n-slate-11"
|
||||
>
|
||||
{{
|
||||
isOnChatwootCloud
|
||||
@@ -86,21 +91,15 @@ export default {
|
||||
v-if="isOnChatwootCloud"
|
||||
class="flex flex-row gap-3 justify-start items-center sm:justify-center"
|
||||
>
|
||||
<woot-button
|
||||
size="medium"
|
||||
variant="hollow"
|
||||
color-scheme="primary"
|
||||
<NextButton
|
||||
outline
|
||||
:label="$t('HELP_CENTER.UPGRADE_PAGE.BUTTON.LEARN_MORE')"
|
||||
@click="openHelpCenterDocs"
|
||||
>
|
||||
{{ $t('HELP_CENTER.UPGRADE_PAGE.BUTTON.LEARN_MORE') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="medium"
|
||||
color-scheme="primary"
|
||||
/>
|
||||
<NextButton
|
||||
:label="$t('HELP_CENTER.UPGRADE_PAGE.BUTTON.UPGRADE')"
|
||||
@click="openBillingPage"
|
||||
>
|
||||
{{ $t('HELP_CENTER.UPGRADE_PAGE.BUTTON.UPGRADE') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -117,14 +116,14 @@ export default {
|
||||
:icon="feature.icon"
|
||||
icon-lib="lucide"
|
||||
:size="26"
|
||||
class="mt-px text-slate-800 dark:text-slate-25"
|
||||
class="mt-px text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="font-semibold text-lg text-slate-800 dark:text-slate-25">
|
||||
<h5 class="font-semibold text-lg text-n-slate-12">
|
||||
{{ feature.title }}
|
||||
</h5>
|
||||
<p class="text-sm leading-6 text-slate-700 dark:text-slate-100">
|
||||
<p class="text-sm leading-6 text-n-slate-12">
|
||||
{{ feature.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { getPortalRoute } from './helpers/routeHelper';
|
||||
|
||||
import HelpCenterPageRouteView from './pages/HelpCenterPageRouteView.vue';
|
||||
@@ -21,21 +22,21 @@ const PortalsLocalesIndexPage = () =>
|
||||
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: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/:locale/:categorySlug?/articles/new'),
|
||||
name: 'portals_articles_new',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesNewPage,
|
||||
},
|
||||
{
|
||||
@@ -43,18 +44,14 @@ const portalRoutes = [
|
||||
':portalSlug/:locale/:categorySlug?/articles/:tab?/edit/:articleSlug'
|
||||
),
|
||||
name: 'portals_articles_edit',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesEditPage,
|
||||
},
|
||||
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/:locale/categories'),
|
||||
name: 'portals_categories_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsCategoriesIndexPage,
|
||||
},
|
||||
{
|
||||
@@ -62,9 +59,7 @@ const portalRoutes = [
|
||||
':portalSlug/:locale/categories/:categorySlug/articles'
|
||||
),
|
||||
name: 'portals_categories_articles_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesIndexPage,
|
||||
},
|
||||
{
|
||||
@@ -72,31 +67,26 @@ const portalRoutes = [
|
||||
':portalSlug/:locale/categories/:categorySlug/articles/:articleSlug'
|
||||
),
|
||||
name: 'portals_categories_articles_edit',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsArticlesEditPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/locales'),
|
||||
name: 'portals_locales_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsLocalesIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute(':portalSlug/settings'),
|
||||
name: 'portals_settings_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent', 'knowledge_base_manage'],
|
||||
},
|
||||
meta,
|
||||
component: PortalsSettingsIndexPage,
|
||||
},
|
||||
{
|
||||
path: getPortalRoute('new'),
|
||||
name: 'portals_new',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'knowledge_base_manage'],
|
||||
},
|
||||
component: PortalsNew,
|
||||
@@ -105,6 +95,7 @@ const portalRoutes = [
|
||||
path: getPortalRoute(':navigationPath'),
|
||||
name: 'portals_index',
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.HELP_CENTER,
|
||||
permissions: ['administrator', 'knowledge_base_manage'],
|
||||
},
|
||||
component: PortalsIndex,
|
||||
|
||||
+2
-2
@@ -64,10 +64,10 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-grow-0 w-full h-full min-h-0 app-wrapper">
|
||||
<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-white dark:bg-slate-900"
|
||||
class="flex flex-1 h-full px-0 overflow-hidden bg-n-background"
|
||||
>
|
||||
<router-view />
|
||||
</section>
|
||||
|
||||
+9
-3
@@ -20,17 +20,23 @@ 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 portalLink = computed(() => {
|
||||
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
|
||||
articleSlugValue,
|
||||
portalCustomDomain
|
||||
);
|
||||
});
|
||||
|
||||
@@ -86,7 +92,7 @@ const fetchArticleDetails = () => {
|
||||
};
|
||||
|
||||
const previewArticle = () => {
|
||||
window.open(portalLink.value, '_blank');
|
||||
window.open(articleLink.value, '_blank');
|
||||
useTrack(PORTALS_EVENTS.PREVIEW_ARTICLE, {
|
||||
status: article.value?.status,
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ onMounted(() => performRouting());
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center w-full bg-n-background text-slate-600 dark:text-slate-200"
|
||||
class="flex items-center justify-center w-full bg-n-background text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
+43
-1
@@ -4,12 +4,16 @@ 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();
|
||||
|
||||
@@ -24,6 +28,15 @@ 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([
|
||||
@@ -106,8 +119,35 @@ const deletePortal = async selectedPortalForDelete => {
|
||||
}
|
||||
};
|
||||
|
||||
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 = 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>
|
||||
|
||||
@@ -118,5 +158,7 @@ const handleDeletePortal = deletePortal;
|
||||
@update-portal="handleUpdatePortal"
|
||||
@update-portal-configuration="handleUpdatePortalConfiguration"
|
||||
@delete-portal="handleDeletePortal"
|
||||
@refresh-status="fetchSSLStatus"
|
||||
@send-cname-instructions="handleSendCnameInstructions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
emptyStateMessage: {
|
||||
type: String,
|
||||
@@ -25,14 +30,12 @@ export default {
|
||||
<div
|
||||
class="items-center justify-center hidden w-full h-full text-center bg-n-background lg:flex"
|
||||
>
|
||||
<span v-if="uiFlags.isFetching" class="my-4 spinner" />
|
||||
<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-slate-600 dark:text-slate-400"
|
||||
/>
|
||||
<span class="text-sm font-medium text-slate-500 dark:text-slate-300">
|
||||
<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>
|
||||
|
||||
@@ -1,219 +1,236 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<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 { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxCard,
|
||||
InboxListHeader,
|
||||
IntersectionObserver,
|
||||
CmdBarConversationSnooze,
|
||||
},
|
||||
setup() {
|
||||
const { uiSettings } = useUISettings();
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
infiniteLoaderOptions: {
|
||||
root: this.$refs.notificationList,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
},
|
||||
page: 1,
|
||||
status: '',
|
||||
type: '',
|
||||
sortOrder: wootConstants.INBOX_SORT_BY.NEWEST,
|
||||
isInboxContextMenuOpen: false,
|
||||
notificationIdToSnooze: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
meta: 'notifications/getMeta',
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
notification: 'notifications/getFilteredNotifications',
|
||||
notificationV4: 'notifications/getFilteredNotificationsV4',
|
||||
inboxById: 'inboxes/getInboxById',
|
||||
}),
|
||||
currentNotificationId() {
|
||||
return Number(this.$route.params.notification_id);
|
||||
},
|
||||
inboxFilters() {
|
||||
return {
|
||||
page: this.page,
|
||||
status: this.status,
|
||||
type: this.type,
|
||||
sortOrder: this.sortOrder,
|
||||
};
|
||||
},
|
||||
notifications() {
|
||||
return this.notification(this.inboxFilters);
|
||||
},
|
||||
notificationsV4() {
|
||||
return this.notificationV4(this.inboxFilters);
|
||||
},
|
||||
showEndOfList() {
|
||||
return this.uiFlags.isAllNotificationsLoaded && !this.uiFlags.isFetching;
|
||||
},
|
||||
showEmptyState() {
|
||||
return !this.uiFlags.isFetching && !this.notifications.length;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
inboxFilters(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.$store.dispatch('notifications/updateNotificationFilters', newVal);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setSavedFilter();
|
||||
this.fetchNotifications();
|
||||
},
|
||||
methods: {
|
||||
stateInbox(inboxId) {
|
||||
return this.inboxById(inboxId);
|
||||
},
|
||||
fetchNotifications() {
|
||||
this.page = 1;
|
||||
this.$store.dispatch('notifications/clear');
|
||||
const filter = this.inboxFilters;
|
||||
this.$store.dispatch('notifications/index', filter);
|
||||
},
|
||||
redirectToInbox() {
|
||||
if (this.$route.name === 'inbox_view') return;
|
||||
this.$router.replace({ name: 'inbox_view' });
|
||||
},
|
||||
loadMoreNotifications() {
|
||||
if (this.uiFlags.isAllNotificationsLoaded) return;
|
||||
this.$store.dispatch('notifications/index', {
|
||||
page: this.page + 1,
|
||||
status: this.status,
|
||||
type: this.type,
|
||||
sortOrder: this.sortOrder,
|
||||
});
|
||||
this.page += 1;
|
||||
},
|
||||
markNotificationAsRead(notification) {
|
||||
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ);
|
||||
const {
|
||||
id,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: primaryActorType,
|
||||
} = notification;
|
||||
this.$store
|
||||
.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount: this.meta.unreadCount,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('INBOX.ALERTS.MARK_AS_READ'));
|
||||
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
|
||||
});
|
||||
},
|
||||
markNotificationAsUnRead(notification) {
|
||||
useTrack(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD);
|
||||
this.redirectToInbox();
|
||||
const { id } = notification;
|
||||
this.$store
|
||||
.dispatch('notifications/unread', {
|
||||
id,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('INBOX.ALERTS.MARK_AS_UNREAD'));
|
||||
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
|
||||
});
|
||||
},
|
||||
deleteNotification(notification) {
|
||||
useTrack(INBOX_EVENTS.DELETE_NOTIFICATION);
|
||||
this.redirectToInbox();
|
||||
this.$store
|
||||
.dispatch('notifications/delete', {
|
||||
notification,
|
||||
unread_count: this.meta.unreadCount,
|
||||
count: this.meta.count,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('INBOX.ALERTS.DELETE'));
|
||||
});
|
||||
},
|
||||
onFilterChange(option) {
|
||||
const { STATUS, TYPE, SORT_ORDER } = wootConstants.INBOX_FILTER_TYPE;
|
||||
if (option.type === STATUS) {
|
||||
this.status = option.selected ? option.key : '';
|
||||
}
|
||||
if (option.type === TYPE) {
|
||||
this.type = option.selected ? option.key : '';
|
||||
}
|
||||
if (option.type === SORT_ORDER) {
|
||||
this.sortOrder = option.key;
|
||||
}
|
||||
this.fetchNotifications();
|
||||
},
|
||||
setSavedFilter() {
|
||||
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
|
||||
const { status, type, sort_by: sortBy } = filterBy;
|
||||
this.status = status;
|
||||
this.type = type;
|
||||
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
|
||||
this.$store.dispatch(
|
||||
'notifications/setNotificationFilters',
|
||||
this.inboxFilters
|
||||
);
|
||||
},
|
||||
openConversation(notification) {
|
||||
const {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
primaryActor: { inboxId },
|
||||
notificationType,
|
||||
} = notification;
|
||||
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);
|
||||
|
||||
if (this.$route.params.notification_id !== id) {
|
||||
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
const infiniteLoaderOptions = computed(() => ({
|
||||
root: notificationList.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
this.$store
|
||||
.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount: this.meta.unreadCount,
|
||||
})
|
||||
.then(() => {
|
||||
this.$store.dispatch('notifications/unReadCount'); // to update the unread count in the store real time
|
||||
});
|
||||
const meta = useMapGetter('notifications/getMeta');
|
||||
const uiFlags = useMapGetter('notifications/getUIFlags');
|
||||
const records = useMapGetter('notifications/getFilteredNotificationsV4');
|
||||
const inboxById = useMapGetter('inboxes/getInboxById');
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
params: { inboxId, notification_id: id },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
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,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: 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-solid-1">
|
||||
<div
|
||||
class="flex flex-col h-full w-full lg:min-w-[400px] lg:max-w-[400px] ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="!currentNotificationId ? 'flex' : 'hidden xl:flex'"
|
||||
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"
|
||||
@@ -222,17 +239,17 @@ export default {
|
||||
/>
|
||||
<div
|
||||
ref="notificationList"
|
||||
class="flex flex-col gap-px w-full h-[calc(100%-56px)] pb-3 overflow-x-hidden px-3 overflow-y-auto divide-y divide-n-weak [&>*:hover]:!border-y-transparent [&>*.active]:!border-y-transparent [&>*:hover+*]:!border-t-transparent [&>*.active+*]:!border-t-transparent"
|
||||
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 notificationsV4"
|
||||
v-for="notificationItem in notifications"
|
||||
:key="notificationItem.id"
|
||||
:inbox-item="notificationItem"
|
||||
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
:class="
|
||||
currentNotificationId === notificationItem.id
|
||||
? 'bg-n-alpha-1 dark:bg-n-alpha-3 click-animation rounded-xl active'
|
||||
currentConversationId === notificationItem.primaryActor?.id
|
||||
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
|
||||
: ''
|
||||
"
|
||||
@mark-notification-as-read="markNotificationAsRead"
|
||||
@@ -242,12 +259,12 @@ export default {
|
||||
@context-menu-close="isInboxContextMenuOpen = false"
|
||||
@click="openConversation(notificationItem)"
|
||||
/>
|
||||
<div v-if="uiFlags.isFetching" class="text-center">
|
||||
<span class="mt-4 mb-4 spinner" />
|
||||
<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-slate-400 dark:text-slate-400"
|
||||
class="p-4 text-sm font-medium text-center text-n-slate-10"
|
||||
>
|
||||
{{ $t('INBOX.LIST.NO_NOTIFICATIONS') }}
|
||||
</p>
|
||||
@@ -262,23 +279,3 @@ export default {
|
||||
<CmdBarConversationSnooze />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.click-animation {
|
||||
animation: click-animation 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes click-animation {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,181 +1,191 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<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 InboxItemHeader from './components/InboxItemHeader.vue';
|
||||
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
|
||||
import InboxEmptyState from './InboxEmptyState.vue';
|
||||
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';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
InboxItemHeader,
|
||||
InboxEmptyState,
|
||||
ConversationBox,
|
||||
},
|
||||
setup() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
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';
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isConversationLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
notification: 'notifications/getFilteredNotifications',
|
||||
currentChat: 'getSelectedChat',
|
||||
activeNotificationById: 'notifications/getNotificationById',
|
||||
conversationById: 'getConversationById',
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
meta: 'notifications/getMeta',
|
||||
}),
|
||||
notifications() {
|
||||
return this.notification({
|
||||
sortOrder: this.activeSortOrder,
|
||||
});
|
||||
},
|
||||
inboxId() {
|
||||
return Number(this.$route.params.inboxId);
|
||||
},
|
||||
notificationId() {
|
||||
return Number(this.$route.params.notification_id);
|
||||
},
|
||||
activeNotification() {
|
||||
return this.activeNotificationById(this.notificationId);
|
||||
},
|
||||
conversationId() {
|
||||
return this.activeNotification?.primary_actor?.id;
|
||||
},
|
||||
totalNotificationCount() {
|
||||
return this.meta.count;
|
||||
},
|
||||
showEmptyState() {
|
||||
return (
|
||||
!this.conversationId ||
|
||||
(!this.notifications?.length && this.uiFlags.isFetching)
|
||||
);
|
||||
},
|
||||
activeNotificationIndex() {
|
||||
return this.notifications?.findIndex(n => n.id === this.notificationId);
|
||||
},
|
||||
activeSortOrder() {
|
||||
const { inbox_filter_by: filterBy = {} } = this.uiSettings;
|
||||
const { sort_by: sortBy } = filterBy;
|
||||
return sortBy || 'desc';
|
||||
},
|
||||
isContactPanelOpen() {
|
||||
if (this.currentChat.id) {
|
||||
const { is_contact_sidebar_open: isContactSidebarOpen } =
|
||||
this.uiSettings;
|
||||
return isContactSidebarOpen;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
conversationId: {
|
||||
immediate: true,
|
||||
handler(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.fetchConversationById();
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agents/get');
|
||||
},
|
||||
methods: {
|
||||
async fetchConversationById() {
|
||||
if (!this.notificationId || !this.conversationId) return;
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
const existingChat = this.findConversation();
|
||||
if (existingChat) {
|
||||
this.setActiveChat(existingChat);
|
||||
return;
|
||||
}
|
||||
this.isConversationLoading = true;
|
||||
await this.$store.dispatch('getConversation', this.conversationId);
|
||||
this.setActiveChat();
|
||||
this.isConversationLoading = false;
|
||||
},
|
||||
setActiveChat() {
|
||||
const selectedConversation = this.findConversation();
|
||||
if (!selectedConversation) return;
|
||||
this.$store
|
||||
.dispatch('setActiveChat', { data: selectedConversation })
|
||||
.then(() => {
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
});
|
||||
},
|
||||
findConversation() {
|
||||
return this.conversationById(this.conversationId);
|
||||
},
|
||||
navigateToConversation(activeIndex, direction) {
|
||||
let updatedIndex;
|
||||
if (direction === 'prev' && activeIndex) {
|
||||
updatedIndex = activeIndex - 1;
|
||||
} else if (
|
||||
direction === 'next' &&
|
||||
activeIndex < this.totalNotificationCount
|
||||
) {
|
||||
updatedIndex = activeIndex + 1;
|
||||
}
|
||||
const targetNotification = this.notifications[updatedIndex];
|
||||
if (targetNotification) {
|
||||
this.openNotification(targetNotification);
|
||||
}
|
||||
},
|
||||
openNotification(notification) {
|
||||
const {
|
||||
id,
|
||||
primary_actor_id: primaryActorId,
|
||||
primary_actor_type: primaryActorType,
|
||||
primary_actor: { meta: { unreadCount } = {} },
|
||||
notification_type: notificationType,
|
||||
} = notification;
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
useTrack(INBOX_EVENTS.OPEN_CONVERSATION_VIA_INBOX, {
|
||||
notificationType,
|
||||
});
|
||||
const isConversationLoading = ref(false);
|
||||
|
||||
this.$store.dispatch('notifications/read', {
|
||||
id,
|
||||
primaryActorId,
|
||||
primaryActorType,
|
||||
unreadCount,
|
||||
});
|
||||
const notification = useMapGetter('notifications/getFilteredNotifications');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const conversationById = useMapGetter('getConversationById');
|
||||
const uiFlags = useMapGetter('notifications/getUIFlags');
|
||||
const meta = useMapGetter('notifications/getMeta');
|
||||
|
||||
this.$router.push({
|
||||
name: 'inbox_view_conversation',
|
||||
params: { notification_id: id },
|
||||
});
|
||||
},
|
||||
onClickNext() {
|
||||
this.navigateToConversation(this.activeNotificationIndex, 'next');
|
||||
},
|
||||
onClickPrev() {
|
||||
this.navigateToConversation(this.activeNotificationIndex, 'prev');
|
||||
},
|
||||
onToggleContactPanel() {
|
||||
this.updateUISettings({
|
||||
is_contact_sidebar_open: !this.isContactPanelOpen,
|
||||
});
|
||||
},
|
||||
},
|
||||
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 xl:w-[calc(100%-400px)]">
|
||||
<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')"
|
||||
@@ -183,7 +193,6 @@ export default {
|
||||
</div>
|
||||
<div v-else class="flex flex-col w-full h-full">
|
||||
<InboxItemHeader
|
||||
class="flex-1"
|
||||
:total-length="totalNotificationCount"
|
||||
:current-index="activeNotificationIndex"
|
||||
:active-notification="activeNotification"
|
||||
@@ -192,19 +201,24 @@ export default {
|
||||
/>
|
||||
<div
|
||||
v-if="isConversationLoading"
|
||||
class="flex items-center h-[calc(100%-56px)] justify-center bg-slate-25 dark:bg-slate-800"
|
||||
class="flex items-center flex-1 my-4 justify-center bg-n-solid-1"
|
||||
>
|
||||
<span class="my-4 spinner" />
|
||||
<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>
|
||||
<ConversationBox
|
||||
v-else
|
||||
class="h-[calc(100%-56px)] [&.conversation-details-wrap]:!border-0"
|
||||
is-inbox-view
|
||||
:inbox-id="inboxId"
|
||||
:is-contact-panel-open="isContactPanelOpen"
|
||||
:is-on-expanded-layout="false"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,27 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import MenuItem from './MenuItem.vue';
|
||||
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MenuItem,
|
||||
ContextMenu,
|
||||
defineProps({
|
||||
contextMenuPosition: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
props: {
|
||||
contextMenuPosition: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ['close', 'selectAction'],
|
||||
methods: {
|
||||
handleClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onMenuItemClick(key) {
|
||||
this.$emit('selectAction', key);
|
||||
this.handleClose();
|
||||
},
|
||||
menuItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'selectAction']);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onMenuItemClick = key => {
|
||||
emit('selectAction', key);
|
||||
handleClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,12 +32,14 @@ export default {
|
||||
@close="handleClose"
|
||||
>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
|
||||
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"
|
||||
:label="item.label"
|
||||
:option="item"
|
||||
variant="icon"
|
||||
class="!w-48"
|
||||
@click.stop="onMenuItemClick(item.key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -115,12 +115,12 @@ export default {
|
||||
|
||||
<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 w-[170px] rounded-xl divide-y divide-n-weak dark:divide-n-strong"
|
||||
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 justify-between p-3 rounded-t-lg h-11">
|
||||
<div class="flex gap-1.5">
|
||||
<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">
|
||||
<span class="text-xs font-medium text-n-slate-12 truncate min-w-0">
|
||||
{{ $t('INBOX.DISPLAY_MENU.SORT') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -132,25 +132,25 @@ export default {
|
||||
trailing-icon
|
||||
xs
|
||||
outline
|
||||
class="w-20"
|
||||
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-20 top-px outline outline-1 outline-n-container dark:outline-n-strong"
|
||||
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-1"
|
||||
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 dark:hover:text-n-brand"
|
||||
class="text-xs font-medium hover:text-n-brand truncate min-w-0 dark:hover:text-n-brand"
|
||||
:class="{
|
||||
'text-n-blue-text dark:text-n-blue-text':
|
||||
activeSort === option.key,
|
||||
@@ -161,7 +161,7 @@ export default {
|
||||
</span>
|
||||
<span
|
||||
v-if="activeSort === option.key"
|
||||
class="i-lucide-check size-2.5 text-n-blue-text"
|
||||
class="i-lucide-check size-2.5 flex-shrink-0 text-n-blue-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,12 +182,12 @@ export default {
|
||||
type="checkbox"
|
||||
:name="option.key"
|
||||
:checked="option.selected"
|
||||
class="m-0 border-[1.5px] shadow border-slate-200 dark:border-slate-600 appearance-none rounded-[4px] w-4 h-4 dark:bg-slate-800 focus:ring-1 focus:ring-slate-100 dark:focus:ring-slate-700 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-woot-700 dark:checked:border-woot-300 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]"
|
||||
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-slate-800 !ml-0 !mr-0 dark:text-slate-100"
|
||||
class="text-xs font-medium text-n-slate-12 !ml-0 !mr-0 dark:text-n-slate-12"
|
||||
>
|
||||
{{ option.name }}
|
||||
</label>
|
||||
|
||||
@@ -109,7 +109,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
|
||||
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"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<BackButton
|
||||
|
||||
@@ -79,9 +79,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between w-full gap-1 h-14 px-4 mb-2">
|
||||
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<h1 class="min-w-0 text-lg font-medium truncate text-n-slate-12">
|
||||
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
|
||||
{{ $t('INBOX.LIST.TITLE') }}
|
||||
</h1>
|
||||
<div class="relative">
|
||||
@@ -113,7 +113,7 @@ export default {
|
||||
<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:md:left-[unset]"
|
||||
class="absolute top-full mt-1 ltr:right-0 ltr:lg:right-[unset] rtl:left-0 rtl:lg:left-[unset]"
|
||||
@option-click="onInboxOptionMenuClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="z-50 flex flex-col 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"
|
||||
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
|
||||
|
||||
@@ -10,8 +10,10 @@ defineProps({
|
||||
<template>
|
||||
<div
|
||||
role="button"
|
||||
class="flex items-center w-full h-8 px-2 py-1 overflow-hidden text-xs font-medium rounded-md cursor-pointer text-n-slate-12 whitespace-nowrap text-ellipsis hover:text-n-blue-text"
|
||||
class="flex items-center w-full h-8 px-2 py-1 rounded-md cursor-pointer hover:text-n-blue-text min-w-0"
|
||||
>
|
||||
{{ label }}
|
||||
<span class="text-xs font-medium truncate text-n-slate-12">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
CONVERSATION_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/inbox-view'),
|
||||
@@ -20,16 +18,14 @@ export const routes = [
|
||||
component: InboxEmptyStateView,
|
||||
meta: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
featureFlag: FEATURE_FLAGS.CHATWOOT_V4,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: ':notification_id',
|
||||
path: ':type/:id',
|
||||
name: 'inbox_view_conversation',
|
||||
component: InboxDetailView,
|
||||
meta: {
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
featureFlag: FEATURE_FLAGS.CHATWOOT_V4,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
-243
@@ -1,243 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NotificationPanelList from './NotificationPanelList.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { ACCOUNT_EVENTS } from '../../../../helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NotificationPanelList,
|
||||
},
|
||||
emits: ['close'],
|
||||
data() {
|
||||
return {
|
||||
pageSize: 15,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
meta: 'notifications/getMeta',
|
||||
records: 'notifications/getNotifications',
|
||||
uiFlags: 'notifications/getUIFlags',
|
||||
}),
|
||||
totalUnreadNotifications() {
|
||||
return this.meta.unreadCount;
|
||||
},
|
||||
noUnreadNotificationAvailable() {
|
||||
return this.meta.unreadCount === 0;
|
||||
},
|
||||
getUnreadNotifications() {
|
||||
return this.records.filter(notification => notification.read_at === null);
|
||||
},
|
||||
currentPage() {
|
||||
return Number(this.meta.currentPage);
|
||||
},
|
||||
lastPage() {
|
||||
if (this.totalUnreadNotifications > 15) {
|
||||
return Math.ceil(this.totalUnreadNotifications / this.pageSize);
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
inFirstPage() {
|
||||
const page = Number(this.meta.currentPage);
|
||||
return page === 1;
|
||||
},
|
||||
inLastPage() {
|
||||
return this.currentPage === this.lastPage;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('notifications/get', { page: 1 });
|
||||
},
|
||||
methods: {
|
||||
onPageChange(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({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: conversationId },
|
||||
});
|
||||
this.$emit('close');
|
||||
},
|
||||
onClickNextPage() {
|
||||
if (!this.inLastPage) {
|
||||
const page = this.currentPage + 1;
|
||||
this.onPageChange(page);
|
||||
}
|
||||
},
|
||||
onClickPreviousPage() {
|
||||
if (!this.inFirstPage) {
|
||||
const page = this.currentPage - 1;
|
||||
this.onPageChange(page);
|
||||
}
|
||||
},
|
||||
onClickFirstPage() {
|
||||
if (!this.inFirstPage) {
|
||||
const page = 1;
|
||||
this.onPageChange(page);
|
||||
}
|
||||
},
|
||||
onClickLastPage() {
|
||||
if (!this.inLastPage) {
|
||||
const page = this.lastPage;
|
||||
this.onPageChange(page);
|
||||
}
|
||||
},
|
||||
onMarkAllDoneClick() {
|
||||
useTrack(ACCOUNT_EVENTS.MARK_AS_READ_NOTIFICATIONS);
|
||||
this.$store.dispatch('notifications/readAll');
|
||||
},
|
||||
openAudioNotificationSettings() {
|
||||
this.$router.push({ name: 'profile_settings_index' });
|
||||
this.closeNotificationPanel();
|
||||
this.$nextTick(() => {
|
||||
const audioSettings = document.getElementById(
|
||||
'profile-settings-notifications'
|
||||
);
|
||||
if (audioSettings) {
|
||||
// TODO [ref](https://github.com/chatwoot/chatwoot/pull/6233#discussion_r1069636890)
|
||||
audioSettings.scrollIntoView(
|
||||
{ behavior: 'smooth', block: 'start' },
|
||||
150
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
closeNotificationPanel() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-mask">
|
||||
<div
|
||||
v-on-clickaway="closeNotificationPanel"
|
||||
class="flex-col h-[90vh] w-[32.5rem] flex justify-between z-10 rounded-md shadow-md absolute bg-white dark:bg-slate-800 left-14 rtl:left-auto rtl:right-14 m-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center justify-between w-full px-6 pt-5 pb-3 border-b border-solid border-slate-50 dark:border-slate-700"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{{ $t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.TITLE') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="totalUnreadNotifications"
|
||||
class="px-2 py-1 ml-2 mr-2 font-semibold rounded-md text-slate-700 dark:text-slate-200 text-xxs bg-slate-50 dark:bg-slate-700"
|
||||
>
|
||||
{{ totalUnreadNotifications }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<woot-button
|
||||
v-if="!noUnreadNotificationAvailable"
|
||||
color-scheme="primary"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="onMarkAllDoneClick"
|
||||
>
|
||||
{{ $t('NOTIFICATIONS_PAGE.MARK_ALL_DONE') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
icon="settings"
|
||||
@click="openAudioNotificationSettings"
|
||||
/>
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
variant="link"
|
||||
size="tiny"
|
||||
icon="dismiss"
|
||||
@click="closeNotificationPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationPanelList
|
||||
:notifications="getUnreadNotifications"
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:on-click-notification="openConversation"
|
||||
:in-last-page="inLastPage"
|
||||
@close="closeNotificationPanel"
|
||||
/>
|
||||
<div
|
||||
v-if="records.length !== 0"
|
||||
class="flex items-center justify-between px-5 py-1"
|
||||
>
|
||||
<div class="flex">
|
||||
<woot-button
|
||||
size="medium"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="inFirstPage"
|
||||
@click="onClickFirstPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-left" size="16" />
|
||||
<fluent-icon
|
||||
icon="chevron-left"
|
||||
size="16"
|
||||
class="rtl:-mr-3 ltr:-ml-3"
|
||||
/>
|
||||
</woot-button>
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
size="medium"
|
||||
icon="chevron-left"
|
||||
:disabled="inFirstPage"
|
||||
@click="onClickPreviousPage"
|
||||
/>
|
||||
</div>
|
||||
<span class="font-semibold text-xxs text-slate-500 dark:text-slate-400">
|
||||
{{ currentPage }} - {{ lastPage }}
|
||||
</span>
|
||||
<div class="flex">
|
||||
<woot-button
|
||||
color-scheme="secondary"
|
||||
variant="clear"
|
||||
size="medium"
|
||||
icon="chevron-right"
|
||||
:disabled="inLastPage"
|
||||
@click="onClickNextPage"
|
||||
/>
|
||||
<woot-button
|
||||
size="medium"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
:disabled="inLastPage"
|
||||
@click="onClickLastPage"
|
||||
>
|
||||
<fluent-icon icon="chevron-right" size="16" />
|
||||
<fluent-icon
|
||||
icon="chevron-right"
|
||||
size="16"
|
||||
class="rtl:-mr-3 ltr:-ml-3"
|
||||
/>
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
props: {
|
||||
notificationItem: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['openNotification'],
|
||||
computed: {
|
||||
notificationAssignee() {
|
||||
const { primary_actor: primaryActor } = this.notificationItem;
|
||||
return primaryActor?.meta?.assignee;
|
||||
},
|
||||
hasNotificationAssignee() {
|
||||
return !!this.notificationAssignee;
|
||||
},
|
||||
notificationAssigneeName() {
|
||||
return this.notificationAssignee?.name || '';
|
||||
},
|
||||
notificationAssigneeThumbnail() {
|
||||
return this.notificationAssignee?.thumbnail || '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
dynamicTime,
|
||||
onClickOpenNotification() {
|
||||
this.$emit('openNotification', this.notificationItem);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<woot-button
|
||||
size="expanded"
|
||||
color-scheme="secondary"
|
||||
variant="link"
|
||||
class="w-full"
|
||||
@click="onClickOpenNotification()"
|
||||
>
|
||||
<div
|
||||
class="flex-row items-center p-2.5 leading-[1.4] border-b border-solid border-slate-50 dark:border-slate-700 flex w-full hover:bg-slate-75 dark:hover:bg-slate-900 hover:rounded-md"
|
||||
>
|
||||
<div
|
||||
v-if="!notificationItem.read_at"
|
||||
class="w-2 h-2 rounded-full bg-woot-500"
|
||||
/>
|
||||
<div v-else class="flex w-2" />
|
||||
<div
|
||||
class="flex-col ml-2.5 overflow-hidden w-full flex justify-between"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center">
|
||||
<span class="font-bold text-slate-800 dark:text-slate-100">
|
||||
{{
|
||||
`#${
|
||||
notificationItem.primary_actor
|
||||
? notificationItem.primary_actor.id
|
||||
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
|
||||
}`
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="text-xxs p-0.5 px-1 my-0 mx-2 bg-slate-50 dark:bg-slate-700 rounded-md"
|
||||
>
|
||||
{{
|
||||
$t(
|
||||
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hasNotificationAssignee">
|
||||
<Thumbnail
|
||||
:src="notificationAssigneeThumbnail"
|
||||
size="16px"
|
||||
:username="notificationAssigneeName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<span
|
||||
class="overflow-hidden font-normal text-slate-700 dark:text-slate-200 whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ notificationItem.push_message_title }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="flex mt-1 font-semibold text-slate-500 dark:text-slate-400 text-xxs"
|
||||
>
|
||||
{{ dynamicTime(notificationItem.last_activity_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
|
||||
import NotificationPanelItem from './NotificationPanelItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NotificationPanelItem,
|
||||
Spinner,
|
||||
EmptyState,
|
||||
},
|
||||
props: {
|
||||
notifications: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onClickNotification: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
inLastPage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
computed: {
|
||||
showEmptyResult() {
|
||||
return !this.isLoading && this.notifications.length === 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openNotificationPage() {
|
||||
if (this.$route.name !== 'notifications_index') {
|
||||
this.$router.push({
|
||||
name: 'notifications_index',
|
||||
});
|
||||
}
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col py-2 px-2.5 overflow-auto h-full flex">
|
||||
<NotificationPanelItem
|
||||
v-for="notificationItem in notifications"
|
||||
v-show="!isLoading"
|
||||
:key="notificationItem.id"
|
||||
:notification-item="notificationItem"
|
||||
@open-notification="onClickNotification"
|
||||
/>
|
||||
<EmptyState
|
||||
v-if="showEmptyResult"
|
||||
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="!isLoading && inLastPage"
|
||||
size="expanded"
|
||||
variant="clear"
|
||||
color-scheme="primary"
|
||||
class-names="mt-3"
|
||||
@click="openNotificationPage"
|
||||
>
|
||||
{{ $t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.ALL_NOTIFICATIONS') }}
|
||||
</woot-button>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="flex items-center justify-center mx-2 my-12 text-sm font-medium"
|
||||
>
|
||||
<Spinner />
|
||||
<span>{{
|
||||
$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.LOADING_UNREAD_MESSAGE')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+32
-25
@@ -1,15 +1,17 @@
|
||||
<script>
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
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: {
|
||||
Thumbnail,
|
||||
Avatar,
|
||||
Spinner,
|
||||
EmptyState,
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
notifications: {
|
||||
@@ -49,17 +51,22 @@ export default {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex-grow flex-shrink h-full px-4 py-8 overflow-hidden bg-white dark:bg-slate-900"
|
||||
class="flex-grow flex-shrink h-full px-4 py-8 overflow-hidden bg-n-background"
|
||||
>
|
||||
<woot-submit-button
|
||||
v-if="notificationMetadata.unreadCount"
|
||||
class="button nice success button--fixed-top"
|
||||
:button-text="$t('NOTIFICATIONS_PAGE.MARK_ALL_DONE')"
|
||||
:loading="isUpdating"
|
||||
@click="onMarkAllDoneClick"
|
||||
/>
|
||||
|
||||
<table class="woot-table notifications-table">
|
||||
<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"
|
||||
@@ -67,9 +74,10 @@ export default {
|
||||
:class="{
|
||||
'is-unread': notificationItem.read_at === null,
|
||||
}"
|
||||
class="border-b border-n-weak"
|
||||
@click="() => onClickNotification(notificationItem)"
|
||||
>
|
||||
<td>
|
||||
<td class="p-2.5 text-n-slate-12">
|
||||
<div
|
||||
class="overflow-hidden flex-view notification-contant--wrap whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
@@ -99,15 +107,16 @@ export default {
|
||||
</span>
|
||||
</td>
|
||||
<td class="thumbnail--column">
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
v-if="notificationItem.primary_actor.meta.assignee"
|
||||
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
|
||||
size="36px"
|
||||
:username="notificationItem.primary_actor.meta.assignee.name"
|
||||
:size="28"
|
||||
:name="notificationItem.primary_actor.meta.assignee.name"
|
||||
rounded-full
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-right timestamp--column">
|
||||
<div class="text-right timestamp--column ltr:mr-2 rtl:ml-2">
|
||||
<span class="notification--created-at">
|
||||
{{ dynamicTime(notificationItem.last_activity_at) }}
|
||||
</span>
|
||||
@@ -134,10 +143,8 @@ export default {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import 'dashboard/assets/scss/mixins';
|
||||
|
||||
.notification--title {
|
||||
@apply text-sm m-0 text-slate-800 dark:text-slate-100;
|
||||
@apply text-sm m-0 text-n-slate-12;
|
||||
}
|
||||
|
||||
.notifications-table {
|
||||
@@ -146,11 +153,11 @@ export default {
|
||||
@apply cursor-pointer;
|
||||
|
||||
&:hover {
|
||||
@apply bg-slate-50 dark:bg-slate-800;
|
||||
@apply bg-n-slate-3;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
@apply bg-slate-100 dark:bg-slate-700;
|
||||
@apply bg-n-slate-4 dark:bg-n-slate-6;
|
||||
}
|
||||
|
||||
> td {
|
||||
@@ -175,11 +182,11 @@ export default {
|
||||
}
|
||||
|
||||
.notification--unread-indicator {
|
||||
@apply w-2.5 h-2.5 rounded-full bg-woot-500 dark:bg-woot-500;
|
||||
@apply w-2.5 h-2.5 rounded-full bg-n-brand;
|
||||
}
|
||||
|
||||
.notification--created-at {
|
||||
@apply text-slate-700 dark:text-slate-200 text-xs;
|
||||
@apply text-n-slate-11 text-xs;
|
||||
}
|
||||
|
||||
.notification--type {
|
||||
@@ -199,6 +206,6 @@ export default {
|
||||
}
|
||||
|
||||
.notification--message-title {
|
||||
@apply text-slate-700 dark:text-slate-100;
|
||||
@apply text-n-slate-12;
|
||||
}
|
||||
</style>
|
||||
|
||||
+1
-15
@@ -68,7 +68,7 @@ export default {
|
||||
:on-mark-all-done-click="onMarkAllDoneClick"
|
||||
/>
|
||||
<TableFooter
|
||||
class="border-t border-slate-75 dark:border-slate-700/50"
|
||||
class="border-t border-n-weak"
|
||||
:current-page="Number(meta.currentPage)"
|
||||
:total-count="meta.count"
|
||||
:page-size="15"
|
||||
@@ -77,17 +77,3 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notification--page {
|
||||
background: var(--white);
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notification--content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,10 +8,9 @@ export const routes = [
|
||||
path: frontendURL('accounts/:accountId/notifications'),
|
||||
component: SettingsWrapper,
|
||||
props: {
|
||||
headerTitle: 'NOTIFICATIONS_PAGE.HEADER',
|
||||
icon: 'alert',
|
||||
headerTitle: '',
|
||||
icon: '',
|
||||
showNewButton: false,
|
||||
showSidemenuIcon: false,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -11,20 +11,11 @@ export default {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
buttonRoute: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
buttonText: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
icon: {
|
||||
default: '',
|
||||
type: String,
|
||||
},
|
||||
showBackButton: { type: Boolean, default: false },
|
||||
showNewButton: { type: Boolean, default: false },
|
||||
backUrl: {
|
||||
type: [String, Object],
|
||||
default: '',
|
||||
@@ -33,10 +24,6 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showSidemenuIcon: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
@@ -54,38 +41,20 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center h-14 min-h-[3.5rem] px-4 py-2 bg-n-background border-b border-n-weak"
|
||||
class="flex justify-between items-center h-20 min-h-[3.5rem] px-4 py-2 bg-n-background"
|
||||
>
|
||||
<h1
|
||||
class="flex items-center mb-0 text-2xl text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
<woot-sidemenu-icon v-if="showSidemenuIcon" />
|
||||
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
|
||||
<BackButton
|
||||
v-if="showBackButton"
|
||||
:button-label="backButtonLabel"
|
||||
:back-url="backUrl"
|
||||
class="ml-2 mr-4"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-if="icon"
|
||||
:icon="icon"
|
||||
:class="iconClass"
|
||||
class="hidden ml-1 mr-2 rtl:ml-2 rtl:mr-1 md:block"
|
||||
/>
|
||||
|
||||
<slot />
|
||||
<span class="text-2xl font-medium text-slate-900 dark:text-slate-100">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</h1>
|
||||
<router-link
|
||||
v-if="showNewButton && isAdmin"
|
||||
:to="buttonRoute"
|
||||
class="button success button--fixed-top px-3.5 py-1 rounded-[5px] flex gap-2"
|
||||
>
|
||||
<fluent-icon icon="add-circle" />
|
||||
<span class="button__content">
|
||||
{{ buttonText }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,7 @@ defineProps({
|
||||
</slot>
|
||||
<p
|
||||
v-else-if="noRecordsFound"
|
||||
class="flex-1 py-20 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
|
||||
class="flex-1 py-20 text-n-slate-12 flex items-center justify-center text-base"
|
||||
>
|
||||
{{ noRecordsMessage }}
|
||||
</p>
|
||||
|
||||
@@ -8,15 +8,13 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full items-start">
|
||||
<h2
|
||||
class="text-xl font-medium mb-1 text-slate-800 dark:text-slate-100 break-words"
|
||||
>
|
||||
<div class="flex flex-col w-full items-start mb-4">
|
||||
<h2 class="text-xl font-medium mb-1 text-n-slate-12 break-words">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p
|
||||
v-dompurify-html="headerContent"
|
||||
class="text-sm w-full text-slate-600 dark:text-slate-300"
|
||||
class="text-sm w-full text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,44 +2,44 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import SettingsHeader from './SettingsHeader.vue';
|
||||
|
||||
const props = defineProps({
|
||||
headerTitle: { type: String, default: '' },
|
||||
headerButtonText: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
keepAlive: { type: Boolean, default: true },
|
||||
newButtonRoutes: { type: Array, default: () => [] },
|
||||
showBackButton: { type: Boolean, default: false },
|
||||
backUrl: { type: [String, Object], default: '' },
|
||||
showSidemenuIcon: { type: Boolean, default: true },
|
||||
fullWidth: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const showNewButton = computed(
|
||||
() => props.newButtonRoutes.length !== 0 && !props.showBackButton
|
||||
const showSettingsHeader = computed(
|
||||
() => props.headerTitle || props.icon || props.showBackButton
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-1 h-full justify-between flex-col m-0 bg-n-background overflow-auto"
|
||||
>
|
||||
<SettingsHeader
|
||||
button-route="new"
|
||||
:icon="icon"
|
||||
:header-title="t(headerTitle)"
|
||||
:button-text="t(headerButtonText)"
|
||||
:show-back-button="showBackButton"
|
||||
:back-url="backUrl"
|
||||
:show-new-button="showNewButton"
|
||||
:show-sidemenu-icon="showSidemenuIcon"
|
||||
/>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="keepAlive">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-else />
|
||||
</router-view>
|
||||
<div class="flex flex-1 flex-col m-0 bg-n-background overflow-auto">
|
||||
<div
|
||||
class="mx-auto w-full flex flex-col flex-1"
|
||||
:class="{ 'max-w-6xl': !fullWidth }"
|
||||
>
|
||||
<SettingsHeader
|
||||
v-if="showSettingsHeader"
|
||||
:icon="icon"
|
||||
:header-title="t(headerTitle)"
|
||||
:show-back-button="showBackButton"
|
||||
:back-url="backUrl"
|
||||
class="sticky top-0 z-20"
|
||||
:class="{ 'max-w-6xl w-full mx-auto': fullWidth }"
|
||||
/>
|
||||
|
||||
<router-view v-slot="{ Component }" class="px-5 flex-1 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minValue, maxValue } from '@vuelidate/validators';
|
||||
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 semver from 'semver';
|
||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
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 AutoResolve from './components/AutoResolve.vue';
|
||||
import AudioTranscription from './components/AudioTranscription.vue';
|
||||
import SectionLayout from './components/SectionLayout.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BaseSettingsHeader,
|
||||
NextButton,
|
||||
AccountId,
|
||||
BuildInfo,
|
||||
AccountDelete,
|
||||
AutoResolve,
|
||||
AudioTranscription,
|
||||
SectionLayout,
|
||||
WithLabel,
|
||||
NextInput,
|
||||
},
|
||||
setup() {
|
||||
const { updateUISettings } = useUISettings();
|
||||
const { enabledLanguages } = useConfig();
|
||||
@@ -27,8 +48,6 @@ export default {
|
||||
domain: '',
|
||||
supportEmail: '',
|
||||
features: {},
|
||||
autoResolveDuration: null,
|
||||
latestChatwootVersion: null,
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -38,17 +57,13 @@ export default {
|
||||
locale: {
|
||||
required,
|
||||
},
|
||||
autoResolveDuration: {
|
||||
minValue: minValue(1),
|
||||
maxValue: maxValue(999),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
getAccount: 'accounts/getAccount',
|
||||
uiFlags: 'accounts/getUIFlags',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
}),
|
||||
showAutoResolutionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
@@ -56,14 +71,10 @@ export default {
|
||||
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
|
||||
);
|
||||
},
|
||||
hasAnUpdateAvailable() {
|
||||
if (!semver.valid(this.latestChatwootVersion)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return semver.lt(
|
||||
this.globalConfig.appVersion,
|
||||
this.latestChatwootVersion
|
||||
showAudioTranscriptionConfig() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.CAPTAIN
|
||||
);
|
||||
},
|
||||
languagesSortedByCode() {
|
||||
@@ -75,25 +86,21 @@ export default {
|
||||
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
|
||||
);
|
||||
},
|
||||
|
||||
getAccountId() {
|
||||
return this.id.toString();
|
||||
currentAccount() {
|
||||
return this.getAccount(this.accountId) || {};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
@@ -102,16 +109,8 @@ export default {
|
||||
methods: {
|
||||
async initializeAccount() {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
locale,
|
||||
id,
|
||||
domain,
|
||||
support_email,
|
||||
features,
|
||||
auto_resolve_duration,
|
||||
latest_chatwoot_version: latestChatwootVersion,
|
||||
} = this.getAccount(this.accountId);
|
||||
const { name, locale, id, domain, support_email, features } =
|
||||
this.getAccount(this.accountId);
|
||||
|
||||
this.$root.$i18n.locale = locale;
|
||||
this.name = name;
|
||||
@@ -120,8 +119,6 @@ export default {
|
||||
this.domain = domain;
|
||||
this.supportEmail = support_email;
|
||||
this.features = features;
|
||||
this.autoResolveDuration = auto_resolve_duration;
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
@@ -139,7 +136,6 @@ export default {
|
||||
name: this.name,
|
||||
domain: this.domain,
|
||||
support_email: this.supportEmail,
|
||||
auto_resolve_duration: this.autoResolveDuration,
|
||||
});
|
||||
this.$root.$i18n.locale = this.locale;
|
||||
this.getAccount(this.id).locale = this.locale;
|
||||
@@ -161,35 +157,37 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink min-w-0 p-6 overflow-auto">
|
||||
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
|
||||
<div
|
||||
class="flex flex-row p-4 border-b border-slate-25 dark:border-slate-800"
|
||||
<div class="flex flex-col max-w-2xl mx-auto w-full">
|
||||
<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')"
|
||||
>
|
||||
<div
|
||||
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
|
||||
<form
|
||||
v-if="!uiFlags.isFetchingItem"
|
||||
class="grid gap-4"
|
||||
@submit.prevent="updateAccount"
|
||||
>
|
||||
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
|
||||
</h4>
|
||||
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
|
||||
</div>
|
||||
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
|
||||
<label :class="{ error: v$.name.$error }">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
|
||||
<input
|
||||
<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"
|
||||
/>
|
||||
<span v-if="v$.name.$error" class="message">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label :class="{ error: v$.locale.$error }">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
|
||||
<select v-model="locale">
|
||||
</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"
|
||||
@@ -198,94 +196,58 @@ export default {
|
||||
{{ lang.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="v$.locale.$error" class="message">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label v-if="featureInboundEmailEnabled">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
|
||||
</label>
|
||||
<label v-if="featureCustomReplyDomainEnabled">
|
||||
{{
|
||||
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
|
||||
}}
|
||||
</label>
|
||||
<label v-if="featureCustomReplyDomainEnabled">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
|
||||
<input
|
||||
</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')"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="featureCustomReplyEmailEnabled">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
|
||||
<input
|
||||
<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')
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="showAutoResolutionConfig"
|
||||
:class="{ error: v$.autoResolveDuration.$error }"
|
||||
>
|
||||
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
|
||||
<input
|
||||
v-model="autoResolveDuration"
|
||||
type="number"
|
||||
:placeholder="
|
||||
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
|
||||
"
|
||||
@blur="v$.autoResolveDuration.$touch"
|
||||
/>
|
||||
<span v-if="v$.autoResolveDuration.$error" class="message">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</WithLabel>
|
||||
<div>
|
||||
<NextButton blue :is-loading="isUpdating" type="submit">
|
||||
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</form>
|
||||
</SectionLayout>
|
||||
|
||||
<div
|
||||
class="flex flex-row p-4 border-slate-25 dark:border-slate-700 text-black-900 dark:text-slate-300"
|
||||
>
|
||||
<div
|
||||
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
|
||||
>
|
||||
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
|
||||
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
|
||||
</h4>
|
||||
<p>
|
||||
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
|
||||
<woot-code :script="getAccountId" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 text-sm text-center">
|
||||
<div>{{ `v${globalConfig.appVersion}` }}</div>
|
||||
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
|
||||
{{
|
||||
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
|
||||
latestChatwootVersion: latestChatwootVersion,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<div class="build-id">
|
||||
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<woot-submit-button
|
||||
class="button nice success button--fixed-top"
|
||||
:button-text="$t('GENERAL_SETTINGS.SUBMIT')"
|
||||
:loading="isUpdating"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||
</div>
|
||||
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||
<AudioTranscription v-if="showAudioTranscriptionConfig" />
|
||||
<AccountId />
|
||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||
<AccountDelete />
|
||||
</div>
|
||||
<BuildInfo />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -9,12 +9,7 @@ export default {
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'GENERAL_SETTINGS.TITLE',
|
||||
icon: 'briefcase',
|
||||
showNewButton: false,
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
+147
@@ -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>
|
||||
+51
@@ -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>
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
<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 SectionLayout from './SectionLayout.vue';
|
||||
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>
|
||||
<SectionLayout
|
||||
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
|
||||
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
|
||||
:hide-content="!isEnabled"
|
||||
with-border
|
||||
>
|
||||
<template #headerActions>
|
||||
<div class="flex justify-end">
|
||||
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
</SectionLayout>
|
||||
</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>
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, required: true },
|
||||
withBorder: { type: Boolean, default: false },
|
||||
hideContent: { type: Boolean, default: false },
|
||||
});
|
||||
</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 class="col-span-3">
|
||||
<h4 class="text-lg font-medium text-n-slate-12">
|
||||
<slot name="title">{{ title }}</slot>
|
||||
</h4>
|
||||
<p class="text-n-slate-11 text-sm 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>
|
||||
@@ -1,105 +1,191 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import AgentBotRow from './components/AgentBotRow.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default {
|
||||
components: { AgentBotRow },
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
agentBots: 'agentBots/getBots',
|
||||
uiFlags: 'agentBots/getUIFlags',
|
||||
}),
|
||||
newAgentBotsURL() {
|
||||
return frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/new`
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agentBots/get');
|
||||
},
|
||||
methods: {
|
||||
async onDeleteAgentBot(bot) {
|
||||
const ok = await this.$refs.confirmDialog.showConfirmation();
|
||||
if (ok) {
|
||||
try {
|
||||
await this.$store.dispatch('agentBots/delete', bot.id);
|
||||
useAlert(this.$t('AGENT_BOTS.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.DELETE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
}
|
||||
},
|
||||
onEditAgentBot(bot) {
|
||||
this.$router.push(
|
||||
frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/${bot.id}`
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
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';
|
||||
|
||||
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 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'),
|
||||
];
|
||||
});
|
||||
|
||||
const selectedBotName = computed(() => selectedBot.value?.name || '');
|
||||
|
||||
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>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-full lg:w-3/5">
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.isFetching"
|
||||
:message="$t('AGENT_BOTS.LIST.LOADING')"
|
||||
/>
|
||||
<table v-else-if="agentBots.length" class="woot-table">
|
||||
<tbody>
|
||||
<AgentBotRow
|
||||
v-for="(agentBot, index) in agentBots"
|
||||
:key="agentBot.id"
|
||||
:agent-bot="agentBot"
|
||||
:index="index"
|
||||
@delete="onDeleteAgentBot"
|
||||
@edit="onEditAgentBot"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="flex flex-col items-center justify-center h-full">
|
||||
{{ $t('AGENT_BOTS.LIST.404') }}
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
:title="t('AGENT_BOTS.HEADER')"
|
||||
:description="t('AGENT_BOTS.DESCRIPTION')"
|
||||
:link-text="t('AGENT_BOTS.LEARN_MORE')"
|
||||
feature-name="agent_bots"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AGENT_BOTS.ADD.TITLE')"
|
||||
@click="openAddModal"
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<table class="min-w-full overflow-x-auto divide-y divide-n-strong">
|
||||
<thead>
|
||||
<th
|
||||
v-for="thHeader in tableHeaders"
|
||||
:key="thHeader"
|
||||
class="py-4 font-semibold text-left ltr:pr-4 rtl:pl-4 text-n-slate-11"
|
||||
>
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody class="flex-1 divide-y divide-n-weak text-n-slate-12">
|
||||
<tr v-for="bot in agentBots" :key="bot.id">
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<Avatar
|
||||
:name="bot.name"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
rounded-full
|
||||
/>
|
||||
<div>
|
||||
<span class="block font-medium break-words">
|
||||
{{ bot.name }}
|
||||
<span
|
||||
v-if="bot.system_bot"
|
||||
class="text-xs text-n-slate-12 bg-n-blue-5 inline-block rounded-md py-0.5 px-1 ltr:ml-1 rtl:mr-1"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ bot.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4 text-sm">
|
||||
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
|
||||
</td>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<Button
|
||||
v-if="!bot.system_bot"
|
||||
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
: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-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
:is-loading="loading[bot.id]"
|
||||
@click="openDeletePopup(bot)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<div class="hidden w-1/3 lg:block">
|
||||
<p v-html="$t('AGENT_BOTS.SIDEBAR_TXT')" />
|
||||
</div>
|
||||
</div>
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="add-circle"
|
||||
>
|
||||
<router-link :to="newAgentBotsURL" class="white-text">
|
||||
{{ $t('AGENT_BOTS.ADD.TITLE') }}
|
||||
</router-link>
|
||||
</woot-button>
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('AGENT_BOTS.DELETE.TITLE')"
|
||||
:description="$t('AGENT_BOTS.DELETE.DESCRIPTION')"
|
||||
<AgentBotModal
|
||||
ref="agentBotModalRef"
|
||||
:type="modalType"
|
||||
:selected-bot="selectedBot"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.bots-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.white-text {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import Bot from './Index.vue';
|
||||
import CsmlEditBot from './csml/Edit.vue';
|
||||
import CsmlNewBot from './csml/New.vue';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsContent from '../Wrapper.vue';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
@@ -11,34 +10,14 @@ export default {
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'AGENT_BOTS.HEADER',
|
||||
icon: 'bot',
|
||||
showNewButton: false,
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'agent_bots',
|
||||
component: Bot,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'csml/new',
|
||||
name: 'agent_bots_csml_new',
|
||||
component: CsmlNewBot,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'csml/:botId',
|
||||
name: 'agent_bots_csml_edit',
|
||||
component: CsmlEditBot,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
<script setup>
|
||||
import { ref, computed, reactive, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { required, helpers, url } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
selectedBot: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const MODAL_TYPES = {
|
||||
CREATE: 'create',
|
||||
EDIT: 'edit',
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const dialogRef = ref(null);
|
||||
const uiFlags = useMapGetter('agentBots/getUIFlags');
|
||||
|
||||
const formState = reactive({
|
||||
botName: '',
|
||||
botDescription: '',
|
||||
botUrl: '',
|
||||
botAvatar: null,
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
|
||||
const [showAccessToken, toggleAccessToken] = useToggle();
|
||||
const accessToken = ref('');
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
botName: {
|
||||
required: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.NAME'),
|
||||
required
|
||||
),
|
||||
},
|
||||
botUrl: {
|
||||
required: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.URL'),
|
||||
required
|
||||
),
|
||||
url: helpers.withMessage(
|
||||
() => t('AGENT_BOTS.FORM.ERRORS.VALID_URL'),
|
||||
url
|
||||
),
|
||||
},
|
||||
},
|
||||
formState
|
||||
);
|
||||
|
||||
const isLoading = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? uiFlags.value.isCreating
|
||||
: uiFlags.value.isUpdating
|
||||
);
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (showAccessToken.value) {
|
||||
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
|
||||
}
|
||||
|
||||
return props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.ADD.TITLE')
|
||||
: t('AGENT_BOTS.EDIT.TITLE');
|
||||
});
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (showAccessToken.value) {
|
||||
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const confirmButtonLabel = computed(() =>
|
||||
props.type === MODAL_TYPES.CREATE
|
||||
? t('AGENT_BOTS.FORM.CREATE')
|
||||
: t('AGENT_BOTS.FORM.UPDATE')
|
||||
);
|
||||
|
||||
const botNameError = computed(() =>
|
||||
v$.value.botName.$error ? v$.value.botName.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const botUrlError = computed(() =>
|
||||
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
|
||||
);
|
||||
|
||||
const showAccessTokenInput = computed(
|
||||
() =>
|
||||
showAccessToken.value ||
|
||||
props.type === MODAL_TYPES.EDIT ||
|
||||
accessToken.value
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formState, {
|
||||
botName: '',
|
||||
botDescription: '',
|
||||
botUrl: '',
|
||||
botAvatar: null,
|
||||
botAvatarUrl: '',
|
||||
});
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleImageUpload = ({ file, url: avatarUrl }) => {
|
||||
formState.botAvatar = file;
|
||||
formState.botAvatarUrl = avatarUrl;
|
||||
};
|
||||
|
||||
const handleAvatarDelete = async () => {
|
||||
if (props.selectedBot?.id) {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'agentBots/deleteAgentBotAvatar',
|
||||
props.selectedBot.id
|
||||
);
|
||||
formState.botAvatar = null;
|
||||
formState.botAvatarUrl = '';
|
||||
useAlert(t('AGENT_BOTS.AVATAR.SUCCESS_DELETE'));
|
||||
} catch (error) {
|
||||
useAlert(t('AGENT_BOTS.AVATAR.ERROR_DELETE'));
|
||||
}
|
||||
} else {
|
||||
formState.botAvatar = null;
|
||||
formState.botAvatarUrl = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid) return;
|
||||
if (showAccessToken.value) return;
|
||||
|
||||
const botData = {
|
||||
name: formState.botName,
|
||||
description: formState.botDescription,
|
||||
outgoing_url: formState.botUrl,
|
||||
bot_type: 'webhook',
|
||||
avatar: formState.botAvatar,
|
||||
};
|
||||
|
||||
const isCreate = props.type === MODAL_TYPES.CREATE;
|
||||
|
||||
try {
|
||||
const actionPayload = isCreate
|
||||
? botData
|
||||
: { id: props.selectedBot.id, data: botData };
|
||||
|
||||
const response = await store.dispatch(
|
||||
`agentBots/${isCreate ? 'create' : 'update'}`,
|
||||
actionPayload
|
||||
);
|
||||
|
||||
const alertKey = isCreate
|
||||
? t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE')
|
||||
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
|
||||
useAlert(alertKey);
|
||||
|
||||
// Show access token after creation
|
||||
if (isCreate) {
|
||||
const { access_token: responseAccessToken, id } = response || {};
|
||||
|
||||
if (id && responseAccessToken) {
|
||||
accessToken.value = responseAccessToken;
|
||||
toggleAccessToken(true);
|
||||
} else {
|
||||
accessToken.value = '';
|
||||
dialogRef.value.close();
|
||||
}
|
||||
} else {
|
||||
dialogRef.value.close();
|
||||
}
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
const errorKey = isCreate
|
||||
? t('AGENT_BOTS.ADD.API.ERROR_MESSAGE')
|
||||
: t('AGENT_BOTS.EDIT.API.ERROR_MESSAGE');
|
||||
useAlert(errorKey);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeForm = () => {
|
||||
if (props.selectedBot && Object.keys(props.selectedBot).length) {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
outgoing_url: botUrl,
|
||||
thumbnail,
|
||||
bot_config: botConfig,
|
||||
access_token: botAccessToken,
|
||||
} = props.selectedBot;
|
||||
formState.botName = name || '';
|
||||
formState.botDescription = description || '';
|
||||
formState.botUrl = botUrl || botConfig?.webhook_url || '';
|
||||
formState.botAvatarUrl = thumbnail || '';
|
||||
|
||||
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
|
||||
accessToken.value = botAccessToken;
|
||||
}
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
};
|
||||
|
||||
const onCopyToken = async value => {
|
||||
await copyTextToClipboard(value);
|
||||
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.COPY_SUCCESSFUL'));
|
||||
};
|
||||
|
||||
const onResetToken = async () => {
|
||||
const response = await store.dispatch(
|
||||
'agentBots/resetAccessToken',
|
||||
props.selectedBot.id
|
||||
);
|
||||
if (response) {
|
||||
accessToken.value = response.access_token;
|
||||
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_SUCCESS'));
|
||||
} else {
|
||||
useAlert(t('AGENT_BOTS.ACCESS_TOKEN.RESET_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (!showAccessToken.value) v$.value?.$reset();
|
||||
accessToken.value = '';
|
||||
toggleAccessToken(false);
|
||||
};
|
||||
|
||||
const onClickClose = () => {
|
||||
closeModal();
|
||||
dialogRef.value.close();
|
||||
};
|
||||
|
||||
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="dialogDescription"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<div
|
||||
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<div class="mb-2 flex flex-col items-start">
|
||||
<span class="mb-2 text-sm font-medium text-n-slate-12">
|
||||
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
|
||||
</span>
|
||||
<Avatar
|
||||
:src="formState.botAvatarUrl"
|
||||
:name="formState.botName"
|
||||
:size="68"
|
||||
allow-upload
|
||||
icon-name="i-lucide-bot-message-square"
|
||||
@upload="handleImageUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="bot-name"
|
||||
v-model="formState.botName"
|
||||
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="botNameError"
|
||||
:message-type="botNameError ? 'error' : 'info'"
|
||||
@blur="v$.botName.$touch()"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
id="bot-description"
|
||||
v-model="formState.botDescription"
|
||||
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="bot-url"
|
||||
v-model="formState.botUrl"
|
||||
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
|
||||
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
|
||||
:message="botUrlError"
|
||||
:message-type="botUrlError ? 'error' : 'info'"
|
||||
@blur="v$.botUrl.$touch()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
|
||||
<label
|
||||
v-if="type === MODAL_TYPES.EDIT"
|
||||
class="mb-0.5 text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
|
||||
</label>
|
||||
<AccessToken
|
||||
v-if="type === MODAL_TYPES.EDIT"
|
||||
:value="accessToken"
|
||||
@on-copy="onCopyToken"
|
||||
@on-reset="onResetToken"
|
||||
/>
|
||||
<AccessToken
|
||||
v-else
|
||||
:value="accessToken"
|
||||
:show-reset-button="false"
|
||||
@on-copy="onCopyToken"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_BOTS.FORM.CANCEL')"
|
||||
@click="onClickClose()"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="!showAccessToken"
|
||||
type="submit"
|
||||
data-testid="label-submit"
|
||||
:label="confirmButtonLabel"
|
||||
:is-loading="isLoading"
|
||||
:disabled="v$.$invalid"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
-81
@@ -1,81 +0,0 @@
|
||||
<script>
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import AgentBotType from './AgentBotType.vue';
|
||||
|
||||
export default {
|
||||
components: { ShowMore, AgentBotType },
|
||||
props: {
|
||||
agentBot: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['edit', 'delete'],
|
||||
computed: {
|
||||
isACSMLTypeBot() {
|
||||
const { bot_type: botType } = this.agentBot;
|
||||
return botType === 'csml';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="space-x-2">
|
||||
<td class="agent-bot--details">
|
||||
<div class="agent-bot--link">
|
||||
{{ agentBot.name }}
|
||||
(<AgentBotType :bot-type="agentBot.bot_type" />)
|
||||
</div>
|
||||
<div class="agent-bot--description">
|
||||
<ShowMore :text="agentBot.description || ''" :limit="120" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="flex justify-end gap-1">
|
||||
<woot-button
|
||||
v-if="isACSMLTypeBot"
|
||||
v-tooltip.top="$t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="$emit('edit', agentBot)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
@click="$emit('delete', agentBot, index)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.agent-bot--link {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: var(--font-weight-medium);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.agent-bot--description {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.agent-bot--type {
|
||||
color: var(--s-600);
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-bottom: var(--space-small);
|
||||
}
|
||||
|
||||
.agent-bot--details {
|
||||
width: 90%;
|
||||
}
|
||||
</style>
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
botType: {
|
||||
type: String,
|
||||
default: 'webhook',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
botTypeConfig: {
|
||||
csml: {
|
||||
label: this.$t('AGENT_BOTS.TYPES.CSML'),
|
||||
thumbnail: '/dashboard/images/agent-bots/csml.png',
|
||||
},
|
||||
webhook: {
|
||||
label: this.$t('AGENT_BOTS.TYPES.WEBHOOK'),
|
||||
thumbnail: '/dashboard/images/agent-bots/webhook.svg',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<img
|
||||
v-tooltip="botTypeConfig[botType].label"
|
||||
class="agent-bot-type--thumbnail"
|
||||
:src="botTypeConfig[botType].thumbnail"
|
||||
:alt="botTypeConfig[botType].label"
|
||||
/>
|
||||
<span>{{ botTypeConfig[botType].label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-bot-type--thumbnail {
|
||||
width: auto;
|
||||
height: var(--space-slab);
|
||||
}
|
||||
</style>
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import CsmlMonacoEditor from './CSMLMonacoEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { CsmlMonacoEditor },
|
||||
props: {
|
||||
agentBot: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['submit'],
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
validations: {
|
||||
bot: {
|
||||
name: { required },
|
||||
csmlContent: { required },
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bot: {
|
||||
name: this.agentBot.name || '',
|
||||
description: this.agentBot.description || '',
|
||||
csmlContent: this.agentBot.bot_config.csml_content || '',
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.v$.$touch();
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.$emit('submit', {
|
||||
id: this.agentBot.id || '',
|
||||
...this.bot,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-row">
|
||||
<div class="w-[68%]">
|
||||
<div class="h-[calc(100vh-56px)] relative">
|
||||
<CsmlMonacoEditor v-model="bot.csmlContent" class="w-full h-full" />
|
||||
<div
|
||||
v-if="v$.bot.csmlContent.$error"
|
||||
class="bg-red-100 dark:bg-red-200 text-white dark:text-white absolute bottom-0 w-full p-2.5 flex items-center text-xs justify-center flex-shrink-0"
|
||||
>
|
||||
<span>{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-[32%] overflow-auto p-4 h-[calc(100vh-56px)]">
|
||||
<form
|
||||
class="flex flex-col justify-between h-full"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<div>
|
||||
<label :class="{ error: v$.bot.name.$error }">
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.LABEL') }}
|
||||
<input
|
||||
v-model="bot.name"
|
||||
type="text"
|
||||
:placeholder="$t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
<span v-if="v$.bot.name.$error" class="message">
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.NAME.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.LABEL') }}
|
||||
<textarea
|
||||
v-model="bot.description"
|
||||
rows="4"
|
||||
:placeholder="
|
||||
$t('AGENT_BOTS.CSML_BOT_EDITOR.DESCRIPTION.PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<woot-button>
|
||||
{{ $t('AGENT_BOTS.CSML_BOT_EDITOR.SUBMIT') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
<script>
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: { LoadingState },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
iframeLoading: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
},
|
||||
mounted() {
|
||||
window.onmessage = e => {
|
||||
if (
|
||||
typeof e.data !== 'string' ||
|
||||
!e.data.startsWith('chatwoot-csml-editor:update')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const csmlContent = e.data.replace('chatwoot-csml-editor:update', '');
|
||||
this.$emit('update:modelValue', csmlContent);
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onEditorLoad() {
|
||||
const frameElement = document.getElementById(`csml-editor--frame`);
|
||||
const eventData = {
|
||||
event: 'editorContext',
|
||||
data: this.modelValue || '',
|
||||
};
|
||||
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
|
||||
this.iframeLoading = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="csml-editor--container">
|
||||
<LoadingState
|
||||
v-if="iframeLoading"
|
||||
:message="$t('AGENT_BOTS.LOADING_EDITOR')"
|
||||
class="dashboard-app_loading-container"
|
||||
/>
|
||||
<iframe
|
||||
id="csml-editor--frame"
|
||||
:src="globalConfig.csmlEditorHost"
|
||||
@load="onEditorLoad"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#csml-editor--frame {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { Spinner, CsmlBotEditor },
|
||||
computed: {
|
||||
agentBot() {
|
||||
return this.$store.getters['agentBots/getBot'](this.$route.params.botId);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('agentBots/show', this.$route.params.botId);
|
||||
},
|
||||
methods: {
|
||||
async updateBot(bot) {
|
||||
try {
|
||||
await this.$store.dispatch('agentBots/update', {
|
||||
id: bot.id,
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
bot_type: 'csml',
|
||||
bot_config: { csml_content: bot.csmlContent },
|
||||
});
|
||||
useAlert(this.$t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.API_ERROR'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CsmlBotEditor v-if="agentBot.id" :agent-bot="agentBot" @submit="updateBot" />
|
||||
<div v-else class="flex flex-col h-auto overflow-auto no-padding">
|
||||
<Spinner />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL } from '../../../../../helper/URLHelper';
|
||||
import CsmlBotEditor from '../components/CSMLBotEditor.vue';
|
||||
|
||||
export default {
|
||||
components: { CsmlBotEditor },
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
},
|
||||
methods: {
|
||||
async saveBot(bot) {
|
||||
try {
|
||||
const agentBot = await this.$store.dispatch('agentBots/create', {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
bot_type: 'csml',
|
||||
bot_config: { csml_content: bot.csmlContent },
|
||||
});
|
||||
if (agentBot) {
|
||||
this.$router.replace(
|
||||
frontendURL(
|
||||
`accounts/${this.accountId}/settings/agent-bots/csml/${agentBot.id}`
|
||||
)
|
||||
);
|
||||
}
|
||||
useAlert(this.$t('AGENT_BOTS.ADD.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('AGENT_BOTS.ADD.API.ERROR_MESSAGE'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CsmlBotEditor :agent-bot="{ bot_config: {} }" @submit="saveBot" />
|
||||
</template>
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import WootSubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
@@ -148,16 +148,19 @@ const addAgent = async () => {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<div class="w-full">
|
||||
<WootSubmitButton
|
||||
:disabled="v$.$invalid || uiFlags.isCreating"
|
||||
:button-text="$t('AGENT_MGMT.ADD.FORM.SUBMIT')"
|
||||
:loading="uiFlags.isCreating"
|
||||
/>
|
||||
<button class="button clear" @click.prevent="emit('close')">
|
||||
{{ $t('AGENT_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_MGMT.ADD.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="emit('close')"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('AGENT_MGMT.ADD.FORM.SUBMIT')"
|
||||
:disabled="v$.$invalid || uiFlags.isCreating"
|
||||
:is-loading="uiFlags.isCreating"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { required, minLength } from '@vuelidate/validators';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import WootSubmitButton from 'dashboard/components/buttons/FormSubmitButton.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Auth from '../../../../api/auth';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
@@ -200,25 +200,31 @@ const resetPassword = async () => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<div class="w-[50%]">
|
||||
<WootSubmitButton
|
||||
:disabled="v$.$invalid || uiFlags.isUpdating"
|
||||
:button-text="$t('AGENT_MGMT.EDIT.FORM.SUBMIT')"
|
||||
:loading="uiFlags.isUpdating"
|
||||
/>
|
||||
<button class="button clear" @click.prevent="emit('close')">
|
||||
{{ $t('AGENT_MGMT.EDIT.CANCEL_BUTTON_TEXT') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-[50%] text-right">
|
||||
<woot-button
|
||||
icon="lock-closed"
|
||||
variant="clear"
|
||||
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
|
||||
<div class="w-[50%] ltr:text-left rtl:text-right">
|
||||
<Button
|
||||
ghost
|
||||
type="button"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="!px-2"
|
||||
:label="$t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_RESET_BUTTON')"
|
||||
@click.prevent="resetPassword"
|
||||
>
|
||||
{{ $t('AGENT_MGMT.EDIT.PASSWORD_RESET.ADMIN_RESET_BUTTON') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[50%] flex justify-end items-center gap-2">
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('AGENT_MGMT.EDIT.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="emit('close')"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('AGENT_MGMT.EDIT.FORM.SUBMIT')"
|
||||
:disabled="v$.$invalid || uiFlags.isUpdating"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
useStoreGetters,
|
||||
@@ -13,6 +13,7 @@ import AddAgent from './AddAgent.vue';
|
||||
import EditAgent from './EditAgent.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
@@ -149,27 +150,27 @@ const confirmDeletion = () => {
|
||||
feature-name="agents"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
class="rounded-md button nice"
|
||||
icon="add-circle"
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
|
||||
@click="openAddPopup"
|
||||
>
|
||||
{{ $t('AGENT_MGMT.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<table class="divide-y divide-slate-75 dark:divide-slate-700">
|
||||
<table class="divide-y divide-n-weak">
|
||||
<tbody class="divide-y divide-n-weak text-n-slate-11">
|
||||
<tr v-for="(agent, index) in agentList" :key="agent.email">
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<Thumbnail
|
||||
<Avatar
|
||||
:src="agent.thumbnail"
|
||||
:username="agent.name"
|
||||
size="40px"
|
||||
:name="agent.name"
|
||||
:status="agent.availability_status"
|
||||
:size="40"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div>
|
||||
<span class="block font-medium capitalize">
|
||||
@@ -191,7 +192,7 @@ const confirmDeletion = () => {
|
||||
{{ getAgentRoleName(agent) }}
|
||||
|
||||
<div
|
||||
class="absolute left-0 z-10 hidden max-w-[300px] w-auto bg-white rounded-xl border border-slate-50 shadow-lg top-14 md:top-12 dark:bg-slate-800 dark:border-slate-700"
|
||||
class="absolute left-0 z-10 hidden max-w-[300px] w-auto bg-white rounded-xl border border-n-weak shadow-lg top-14 md:top-12 dark:bg-n-solid-2"
|
||||
:class="{ 'group-hover:block': agent.custom_role_id }"
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-4">
|
||||
@@ -225,24 +226,22 @@ const confirmDeletion = () => {
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<div class="flex justify-end gap-1">
|
||||
<woot-button
|
||||
<Button
|
||||
v-if="showEditAction(agent)"
|
||||
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
class-names="grey-btn"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="openEditPopup(agent)"
|
||||
/>
|
||||
<woot-button
|
||||
<Button
|
||||
v-if="showDeleteAction(agent)"
|
||||
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
class-names="grey-btn"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
:is-loading="loading[agent.id]"
|
||||
@click="openDeletePopup(agent, index)"
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import AgentHome from './Index.vue';
|
||||
@@ -19,6 +20,7 @@ export default {
|
||||
name: 'agent_list',
|
||||
component: AgentHome,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,7 +6,12 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
|
||||
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
|
||||
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
@@ -35,8 +40,6 @@ export default {
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
models: ATTRIBUTE_MODELS,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
values: [],
|
||||
options: [],
|
||||
show: true,
|
||||
@@ -48,6 +51,18 @@ export default {
|
||||
...mapGetters({
|
||||
uiFlags: 'getUIFlags',
|
||||
}),
|
||||
models() {
|
||||
return ATTRIBUTE_MODELS.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_MODELS.${item.key}`),
|
||||
}));
|
||||
},
|
||||
types() {
|
||||
return ATTRIBUTE_TYPES.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
isMultiselectInvalid() {
|
||||
return this.isTouched && this.values.length === 0;
|
||||
},
|
||||
@@ -241,7 +256,10 @@ export default {
|
||||
@close="onTouch"
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<label v-show="isMultiselectInvalid" class="error-message">
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -270,13 +288,18 @@ export default {
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.REGEX_CUE.PLACEHOLDER')"
|
||||
/>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-submit-button
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
|
||||
:disabled="isButtonDisabled"
|
||||
:button-text="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
|
||||
/>
|
||||
<button class="button clear" @click.prevent="onClose">
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -286,26 +309,12 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.key-value {
|
||||
padding: 0 var(--space-small) var(--space-small) 0;
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
.error-message {
|
||||
color: var(--r-400);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
::v-deep {
|
||||
.multiselect__tags {
|
||||
border: 1px solid var(--r-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
@@ -318,7 +327,7 @@ export default {
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: var(--border-radius-normal);
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,8 @@ import EditAttribute from './EditAttribute.vue';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attributeModel: {
|
||||
type: String,
|
||||
@@ -98,9 +100,7 @@ const tableHeaders = computed(() => {
|
||||
{{ tableHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
|
||||
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
@@ -113,7 +113,11 @@ const tableHeaders = computed(() => {
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_type }}
|
||||
{{
|
||||
$t(
|
||||
`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${attribute.attribute_display_type?.toUpperCase()}`
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
@@ -122,22 +126,20 @@ const tableHeaders = computed(() => {
|
||||
</td>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<woot-button
|
||||
<Button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class-names="grey-btn"
|
||||
icon="edit"
|
||||
icon="i-lucide-pen"
|
||||
slate
|
||||
xs
|
||||
faded
|
||||
@click="openEditPopup(attribute)"
|
||||
/>
|
||||
<woot-button
|
||||
<Button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
class-names="grey-btn"
|
||||
icon="i-lucide-trash-2"
|
||||
xs
|
||||
ruby
|
||||
faded
|
||||
@click="openDelete(attribute)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,12 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {},
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
props: {
|
||||
selectedAttribute: {
|
||||
type: Object,
|
||||
@@ -28,7 +32,6 @@ export default {
|
||||
regexPattern: null,
|
||||
regexCue: null,
|
||||
regexEnabled: false,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
values: [],
|
||||
@@ -55,6 +58,12 @@ export default {
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
types() {
|
||||
return ATTRIBUTE_TYPES.map(item => ({
|
||||
...item,
|
||||
option: this.$t(`ATTRIBUTES_MGMT.ATTRIBUTE_TYPES.${item.key}`),
|
||||
}));
|
||||
},
|
||||
setAttributeListValue() {
|
||||
return this.selectedAttribute.attribute_values.map(values => ({
|
||||
name: values,
|
||||
@@ -80,9 +89,9 @@ export default {
|
||||
selectedAttributeType() {
|
||||
return this.types.find(
|
||||
item =>
|
||||
item.option.toLowerCase() ===
|
||||
item.key.toLowerCase() ===
|
||||
this.selectedAttribute.attribute_display_type
|
||||
).id;
|
||||
)?.id;
|
||||
},
|
||||
keyErrorMessage() {
|
||||
if (!this.v$.attributeKey.isKey) {
|
||||
@@ -232,7 +241,10 @@ export default {
|
||||
taggable
|
||||
@tag="addTagValue"
|
||||
/>
|
||||
<label v-show="isMultiselectInvalid" class="error-message">
|
||||
<label
|
||||
v-show="isMultiselectInvalid"
|
||||
class="text-n-ruby-9 dark:text-n-ruby-9 text-sm font-normal mt-1"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LIST.ERROR') }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -262,12 +274,19 @@ export default {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
|
||||
<woot-button :is-loading="isUpdating" :disabled="isButtonDisabled">
|
||||
{{ $t('ATTRIBUTES_MGMT.EDIT.UPDATE_BUTTON_TEXT') }}
|
||||
</woot-button>
|
||||
<woot-button variant="clear" @click.prevent="onClose">
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT') }}
|
||||
</woot-button>
|
||||
<NextButton
|
||||
faded
|
||||
slate
|
||||
type="reset"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.CANCEL_BUTTON_TEXT')"
|
||||
@click.prevent="onClose"
|
||||
/>
|
||||
<NextButton
|
||||
type="submit"
|
||||
:label="$t('ATTRIBUTES_MGMT.EDIT.UPDATE_BUTTON_TEXT')"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isButtonDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -275,26 +294,12 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.key-value {
|
||||
padding: 0 var(--space-small) var(--space-small) 0;
|
||||
padding: 0 0.5rem 0.5rem 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
.error-message {
|
||||
color: var(--r-400);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
::v-deep {
|
||||
.multiselect__tags {
|
||||
border: 1px solid var(--r-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
@@ -307,7 +312,7 @@ export default {
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: var(--border-radius-normal);
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@ import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AddAttribute from './AddAttribute.vue';
|
||||
import CustomAttribute from './CustomAttribute.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
|
||||
@@ -68,19 +69,17 @@ const onClickTabChange = index => {
|
||||
feature-name="custom_attributes"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
class="button nice rounded-md"
|
||||
icon="add-circle"
|
||||
<Button
|
||||
icon="i-lucide-circle-plus"
|
||||
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
|
||||
@click="openAddPopup"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
/>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #preBody>
|
||||
<woot-tabs
|
||||
class="font-medium [&_.tabs]:p-0 mb-4"
|
||||
class="font-medium [&_ul]:p-0 mb-4"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
@@ -90,6 +89,7 @@ const onClickTabChange = index => {
|
||||
:index="index"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
is-compact
|
||||
/>
|
||||
</woot-tabs>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import AttributesHome from './Index.vue';
|
||||
@@ -19,6 +20,7 @@ export default {
|
||||
name: 'attributes_list',
|
||||
component: AttributesHome,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
export const ATTRIBUTE_MODELS = [
|
||||
{
|
||||
id: 0,
|
||||
option: 'Conversation',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Contact',
|
||||
},
|
||||
{ id: 0, key: 'CONVERSATION' },
|
||||
{ id: 1, key: 'CONTACT' },
|
||||
];
|
||||
|
||||
export const ATTRIBUTE_TYPES = [
|
||||
{
|
||||
id: 0,
|
||||
option: 'Text',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Number',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
option: 'Link',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
option: 'Date',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
option: 'List',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
option: 'Checkbox',
|
||||
},
|
||||
{ id: 0, key: 'TEXT' },
|
||||
{ id: 1, key: 'NUMBER' },
|
||||
{ id: 4, key: 'LINK' },
|
||||
{ id: 5, key: 'DATE' },
|
||||
{ id: 6, key: 'LIST' },
|
||||
{ id: 7, key: 'CHECKBOX' },
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user