Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Muhsin Keloth
2025-08-14 13:56:49 +05:30
committed by GitHub
3651 changed files with 197936 additions and 49575 deletions
@@ -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,
},
],
},
],
@@ -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 its 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 23 clarifying questions. Use [Product Match](tool://product_match[user_needs]) and suggest 23 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"
/>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"
/>
@@ -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')"
@@ -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,
@@ -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>
@@ -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>
@@ -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,
},
},
],
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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: '',
@@ -0,0 +1,147 @@
<script setup>
import { computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('accounts/getUIFlags');
const { currentAccount } = useAccount();
const [showDeletePopup, toggleDeletePopup] = useToggle();
const confirmPlaceHolderText = computed(() => {
return `${t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER', {
accountName: currentAccount.value.name,
})}`;
});
const isMarkedForDeletion = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return !!custom_attributes.marked_for_deletion_at;
});
const markedForDeletionDate = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
});
const markedForDeletionReason = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
});
const formattedDeletionDate = computed(() => {
if (!markedForDeletionDate.value) return '';
return markedForDeletionDate.value.toLocaleString();
});
const markedForDeletionMessage = computed(() => {
const params = { deletionDate: formattedDeletionDate.value };
if (markedForDeletionReason.value === 'manual_deletion') {
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_MANUAL`,
params
);
}
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_INACTIVITY`,
params
);
});
function handleDeletionError(error) {
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
}
async function markAccountForDeletion() {
toggleDeletePopup(false);
try {
// Use the enterprise API to toggle deletion with delete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
handleDeletionError(error);
}
}
async function clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
}
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE')"
:description="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE')"
with-border
>
<div v-if="isMarkedForDeletion">
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-n-ruby-4 rounded">
<p class="mb-4">
{{ markedForDeletionMessage }}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="toggleDeletePopup(true)"
/>
</div>
</SectionLayout>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')"
:confirm-value="currentAccount.name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="toggleDeletePopup(false)"
/>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE')"
with-border
>
<woot-code :script="getAccountId" />
</SectionLayout>
</template>
@@ -0,0 +1,51 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>
@@ -0,0 +1,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>
@@ -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'],
},
},
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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