Merge branch 'develop' into feat/shopify-app-store-install-flow

This commit is contained in:
Muhsin Keloth
2026-02-19 16:02:22 +05:30
committed by GitHub
197 changed files with 5241 additions and 4640 deletions
@@ -16,7 +16,7 @@ onMounted(() => {
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1 px-6"
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1"
>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
@@ -154,7 +154,7 @@ onMounted(() => {
t('COMPANIES.EMPTY_STATE.TITLE')
}}</span>
</div>
<div v-else class="flex flex-col gap-4 p-4">
<div v-else class="flex flex-col gap-4">
<CompaniesCard
v-for="company in companies"
:id="company.id"
@@ -493,7 +493,7 @@ onMounted(async () => {
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
<ContactsList
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@@ -308,17 +308,5 @@ onMounted(() => {
.contact--profile {
@apply pb-3 border-b border-solid border-n-weak;
}
.conversation--actions .multiselect-wrap--small {
.multiselect {
@apply box-border pl-6;
}
.multiselect__element {
span {
@apply w-full;
}
}
}
}
</style>
@@ -211,7 +211,7 @@ export default {
<template>
<div>
<div class="multiselect-wrap--small">
<div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
@@ -242,7 +242,7 @@ export default {
@select="onClickAssignAgent"
/>
</div>
<div class="multiselect-wrap--small">
<div>
<ContactDetailsItem
compact
:title="$t('CONVERSATION_SIDEBAR.TEAM_LABEL')"
@@ -261,7 +261,7 @@ export default {
@select="onClickAssignTeam"
/>
</div>
<div class="multiselect-wrap--small">
<div>
<ContactDetailsItem compact :title="$t('CONVERSATION.PRIORITY.TITLE')" />
<MultiselectDropdown
:options="priorityOptions"
@@ -79,9 +79,11 @@ export default {
</script>
<template>
<div class="flex items-center justify-between w-full gap-1 h-12 px-3">
<div
class="flex items-center justify-between w-full gap-1 h-[3.25rem] ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3"
>
<div class="flex items-center gap-2 min-w-0 flex-1">
<h1 class="min-w-0 text-base font-medium truncate text-n-slate-12">
<h1 class="text-heading-2 truncate text-n-slate-12 min-w-0">
{{ $t('INBOX.LIST.TITLE') }}
</h1>
<div class="relative">
@@ -91,7 +93,7 @@ export default {
trailing-icon
slate
xs
faded
:variant="showInboxDisplayMenu ? 'faded' : 'solid'"
@click="openInboxDisplayMenu"
/>
<InboxDisplayMenu
@@ -106,8 +108,8 @@ export default {
<NextButton
icon="i-lucide-sliders-vertical"
slate
xs
faded
sm
:variant="showInboxOptionMenu ? 'faded' : 'ghost'"
@click="openInboxOptionsMenu"
/>
<InboxOptionMenu
@@ -1,17 +1,12 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper.vue';
import SettingsWrapper from '../settings/SettingsWrapper.vue';
import NotificationsView from './components/NotificationsView.vue';
export const routes = [
{
path: frontendURL('accounts/:accountId/notifications'),
component: SettingsWrapper,
props: {
headerTitle: '',
icon: '',
showNewButton: false,
},
children: [
{
path: '',
@@ -41,14 +41,14 @@ export default {
<template>
<div
class="flex justify-between items-center h-20 min-h-[3.5rem] px-4 py-2 bg-n-surface-1"
class="flex justify-between items-center h-20 min-h-[3.5rem] px-6 py-2 bg-n-surface-1"
>
<h1 class="flex items-center mb-0 text-2xl text-n-slate-12">
<BackButton
v-if="showBackButton"
:button-label="backButtonLabel"
:back-url="backUrl"
class="ml-2 mr-4"
class="ltr:mr-4 rtl:ml-4"
/>
<slot />
@@ -20,7 +20,7 @@ defineProps({
</script>
<template>
<div class="flex flex-col w-full h-full gap-8 font-inter">
<div class="flex flex-col w-full h-full gap-4 font-inter">
<slot name="header" />
<!-- Added to render any templates that should be rendered before body -->
<main>
@@ -8,13 +8,13 @@ export default {
</script>
<template>
<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">
<div class="flex flex-col gap-1.5 w-full items-start mb-4">
<h2 class="text-heading-1 text-n-slate-12 break-words">
{{ headerTitle }}
</h2>
<p
v-dompurify-html="headerContent"
class="text-sm w-full text-n-slate-11"
class="text-body-main w-full text-n-slate-11"
/>
</div>
</template>
@@ -1,22 +1,26 @@
<script setup>
import { useRoute } from 'vue-router';
defineProps({
keepAlive: {
type: Boolean,
default: true,
},
});
const route = useRoute();
</script>
<template>
<div
class="flex flex-col w-full h-full m-0 p-6 sm:py-8 lg:px-16 overflow-auto bg-n-surface-1 font-inter"
class="flex flex-col w-full h-full m-0 pb-8 pt-4 px-6 overflow-auto bg-n-surface-1"
>
<div class="flex items-start w-full max-w-6xl mx-auto">
<div class="flex items-start w-full max-w-5xl mx-auto">
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
<component :is="Component" :key="route.fullPath" />
</keep-alive>
<component :is="Component" v-else />
<component :is="Component" v-else :key="route.fullPath" />
</router-view>
</div>
</div>
@@ -8,7 +8,6 @@ const props = defineProps({
keepAlive: { type: Boolean, default: true },
showBackButton: { type: Boolean, default: false },
backUrl: { type: [String, Object], default: '' },
fullWidth: { type: Boolean, default: false },
});
const { t } = useI18n();
@@ -19,27 +18,21 @@ const showSettingsHeader = computed(
</script>
<template>
<div class="flex flex-1 flex-col m-0 bg-n-surface-1 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 }"
/>
<div class="flex flex-col h-full m-0 bg-n-surface-1 w-full">
<SettingsHeader
v-if="showSettingsHeader"
:icon="icon"
:header-title="t(headerTitle)"
:show-back-button="showBackButton"
:back-url="backUrl"
class="z-20 max-w-7xl w-full mx-auto"
/>
<router-view v-slot="{ Component }" class="px-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>
<router-view v-slot="{ Component }" class="px-4 overflow-hidden">
<component :is="Component" v-if="!keepAlive" :key="$route.fullPath" />
<keep-alive v-else>
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>
</div>
</template>
@@ -146,12 +146,13 @@ export default {
</script>
<template>
<div class="flex flex-col max-w-2xl mx-auto w-full">
<div class="flex flex-col w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<div class="flex-grow flex-shrink min-w-0 mt-3">
<SectionLayout
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
class="!pt-0"
>
<form
v-if="!uiFlags.isFetchingItem"
@@ -129,14 +129,14 @@ const toggleAutoResolve = async () => {
>
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<h3 class="text-base font-medium text-n-slate-12">
<h3 class="text-heading-2 text-n-slate-12">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE') }}
</h3>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
<p class="mb-0 text-body-para text-n-slate-11">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE') }}
</p>
</div>
@@ -20,8 +20,16 @@ const { t } = useI18n();
}"
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12 flex items-center gap-2">
<div
v-if="
title || beta || $slots.title || description || $slots.description
"
class="col-span-3"
>
<h4
v-if="title || beta || $slots.title"
class="text-heading-2 text-n-slate-12 flex items-center gap-2"
>
<slot name="title">{{ title }}</slot>
<div
v-if="beta"
@@ -31,7 +39,10 @@ const { t } = useI18n();
{{ t('GENERAL.BETA') }}
</div>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<p
v-if="description || $slots.description"
class="text-n-slate-11 text-body-main mt-2"
>
<slot name="description">{{ description }}</slot>
</p>
</div>
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -10,6 +11,11 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AgentBotModal from './components/AgentBotModal.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const MODAL_TYPES = {
CREATE: 'create',
@@ -23,6 +29,7 @@ const agentBots = useMapGetter('agentBots/getBots');
const uiFlags = useMapGetter('agentBots/getUIFlags');
const selectedBot = ref({});
const searchQuery = ref('');
const loading = ref({});
const modalType = ref(MODAL_TYPES.CREATE);
const agentBotModalRef = ref(null);
@@ -32,11 +39,18 @@ const tableHeaders = computed(() => {
return [
t('AGENT_BOTS.LIST.TABLE_HEADER.DETAILS'),
t('AGENT_BOTS.LIST.TABLE_HEADER.URL'),
t('AGENT_BOTS.LIST.TABLE_HEADER.ACTIONS'),
];
});
const selectedBotName = computed(() => selectedBot.value?.name || '');
const filteredAgentBots = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentBots.value;
return picoSearch(agentBots.value, query, ['name', 'description']);
});
const openAddModal = () => {
modalType.value = MODAL_TYPES.CREATE;
selectedBot.value = {};
@@ -86,87 +100,97 @@ onMounted(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="t('AGENT_BOTS.HEADER')"
:description="t('AGENT_BOTS.DESCRIPTION')"
:link-text="t('AGENT_BOTS.LEARN_MORE')"
:search-placeholder="t('AGENT_BOTS.SEARCH_PLACEHOLDER')"
feature-name="agent_bots"
>
<template v-if="agentBots?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('AGENT_BOTS.COUNT', { n: agentBots.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_BOTS.ADD.TITLE')"
size="sm"
@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') }}
<BaseTable
:headers="tableHeaders"
:items="filteredAgentBots"
:no-data-message="
searchQuery ? t('AGENT_BOTS.NO_RESULTS') : t('AGENT_BOTS.LIST.404')
"
>
<template #row="{ items }">
<BaseTableRow v-for="bot in items" :key="bot.id" :item="bot">
<template #default>
<BaseTableCell class="max-w-0">
<div class="flex items-center gap-4 min-w-0">
<Avatar
:name="bot.name"
:src="bot.thumbnail"
:size="40"
class="flex-shrink-0"
/>
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-12 truncate">
{{ bot.name }}
</span>
<span
v-if="bot.system_bot"
class="text-xs text-n-slate-12 bg-n-blue-5 rounded-md py-0.5 px-1 flex-shrink-0"
>
{{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
</span>
</div>
<span class="text-body-main text-n-slate-11 block truncate">
{{ bot.description }}
</span>
</span>
<span class="text-sm text-n-slate-11">
{{ bot.description }}
</span>
</div>
</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>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<span class="text-body-main text-n-slate-11 truncate block">
{{ bot.outgoing_url || bot.bot_config?.webhook_url }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[bot.id]"
@click="openEditModal(bot)"
/>
<Button
v-if="!bot.system_bot"
v-tooltip.top="t('AGENT_BOTS.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<AgentBotModal
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import { computed, onMounted, ref } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import { picoSearch } from '@scmmishra/pico-search';
import {
useStoreGetters,
useStore,
@@ -25,6 +26,7 @@ const showDeletePopup = ref(false);
const showEditPopup = ref(false);
const agentAPI = ref({ message: '' });
const currentAgent = ref({});
const searchQuery = ref('');
const deleteConfirmText = computed(
() => `${t('AGENT_MGMT.DELETE.CONFIRM.YES')} ${currentAgent.value.name}`
@@ -37,6 +39,13 @@ const deleteMessage = computed(() => {
});
const agentList = computed(() => getters['agents/getAgents'].value);
const filteredAgentList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return agentList.value;
return picoSearch(agentList.value, query, ['name', 'email']);
});
const uiFlags = computed(() => getters['agents/getUIFlags'].value);
const currentUserId = computed(() => getters.getCurrentUserID.value);
const customRoles = useMapGetter('customRole/getCustomRoles');
@@ -144,112 +153,127 @@ const confirmDeletion = () => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('AGENT_MGMT.HEADER')"
:description="$t('AGENT_MGMT.DESCRIPTION')"
:link-text="$t('AGENT_MGMT.LEARN_MORE')"
:search-placeholder="$t('AGENT_MGMT.SEARCH_PLACEHOLDER')"
feature-name="agents"
>
<template v-if="agentList?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('AGENT_MGMT.COUNT', { n: agentList.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AGENT_MGMT.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<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">
<Avatar
:src="agent.thumbnail"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
rounded-full
/>
<div>
<span class="block font-medium capitalize">
{{ agent.name }}
</span>
<span>{{ agent.email }}</span>
</div>
</div>
</td>
<td class="relative py-4 ltr:pr-4 rtl:pl-4">
<span
class="block font-medium w-fit"
:class="{
'hover:text-gray-900 group cursor-pointer':
agent.custom_role_id,
}"
>
{{ getAgentRoleName(agent) }}
<div
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 }"
<span
v-if="!filteredAgentList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('AGENT_MGMT.NO_RESULTS') }}
</span>
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
<div
v-for="(agent, index) in filteredAgentList"
:key="agent.email"
class="flex justify-between flex-row items-start gap-4 py-4"
>
<div class="flex items-center gap-4">
<Avatar
:src="agent.thumbnail"
:name="agent.name"
:status="agent.availability_status"
:size="40"
hide-offline-status
/>
<div class="flex flex-col gap-1.5 items-start">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ agent.name }}
</span>
<div class="flex items-center gap-2">
<span class="text-body-main text-n-slate-11">
{{ agent.email }}
</span>
<div class="w-px h-3 bg-n-strong rounded-lg" />
<span
class="block w-fit text-body-main text-n-slate-11 relative"
:class="{
'hover:text-n-slate-12 group cursor-pointer':
agent.custom_role_id,
}"
>
<div class="flex flex-col gap-1 p-4">
<span class="font-semibold">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="pl-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="font-normal"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
{{ getAgentRoleName(agent) }}
<div
class="absolute ltr:left-0 rtl:right-0 z-10 hidden w-[300px] bg-n-alpha-3 backdrop-blur-[100px] rounded-xl outline outline-1 outline-n-container shadow-lg top-14 md:top-12"
:class="{ 'group-hover:block': agent.custom_role_id }"
>
<div class="flex flex-col gap-1 p-4">
<span class="text-heading-3 text-n-slate-12">
{{ $t('AGENT_MGMT.LIST.AVAILABLE_CUSTOM_ROLE') }}
</span>
<ul class="ltr:pl-4 rtl:pr-4 mb-0 list-disc">
<li
v-for="permission in getAgentRolePermissions(agent)"
:key="permission"
class="text-body-main text-n-slate-11"
>
{{
$t(
`CUSTOM_ROLE.PERMISSIONS.${permission.toUpperCase()}`
)
}}
</li>
</ul>
</div>
</div>
</div>
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span v-if="agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span v-if="!agent.confirmed">
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</td>
<td class="py-4">
<div class="flex justify-end gap-1">
<Button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(agent)"
/>
<Button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</span>
<div class="w-px h-3 bg-n-strong rounded-lg" />
<span
v-if="agent.confirmed"
class="text-body-main text-n-slate-11"
>
{{ $t('AGENT_MGMT.LIST.VERIFIED') }}
</span>
<span
v-if="!agent.confirmed"
class="text-body-main text-n-slate-11"
>
{{ $t('AGENT_MGMT.LIST.VERIFICATION_PENDING') }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex justify-end gap-3">
<Button
v-if="showEditAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="openEditPopup(agent)"
/>
<Button
v-if="showDeleteAction(agent)"
v-tooltip.top="$t('AGENT_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
</div>
</div>
</div>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
@@ -93,7 +93,7 @@ const handleClick = key => {
</template>
<template #body>
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6">
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mt-4">
<AssignmentCard
v-for="item in agentAssignments"
:key="item.key"
@@ -88,9 +88,9 @@ const handleSubmit = async formState => {
</script>
<template>
<SettingsLayout class="xl:px-44">
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
@@ -251,9 +251,12 @@ watch(routeId, fetchPolicyData, { immediate: true });
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
@@ -96,7 +96,7 @@ onMounted(() => {
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
@@ -108,7 +108,7 @@ onMounted(() => {
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-8">
<div class="flex flex-col gap-4 pt-4">
<AssignmentPolicyCard
v-for="policy in agentAssignmentsPolicies"
:key="policy.id"
@@ -67,9 +67,9 @@ const handleSubmit = async formState => {
</script>
<template>
<SettingsLayout class="xl:px-44">
<SettingsLayout class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto">
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
@@ -184,9 +184,12 @@ onMounted(() => store.dispatch('agents/get'));
</script>
<template>
<SettingsLayout :is-loading="uiFlags.isFetchingItem" class="xl:px-44">
<SettingsLayout
:is-loading="uiFlags.isFetchingItem"
class="w-full max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between mb-4 min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</div>
</template>
@@ -94,7 +94,7 @@ onMounted(() => {
"
>
<template #header>
<div class="flex items-center gap-2 w-full justify-between">
<div class="flex items-center gap-2 w-full justify-between min-h-10">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
<Button icon="i-lucide-plus" md @click="onClickCreatePolicy">
{{
@@ -106,7 +106,7 @@ onMounted(() => {
</div>
</template>
<template #body>
<div class="flex flex-col gap-4 pt-8">
<div class="flex flex-col gap-4 pt-4">
<AgentCapacityPolicyCard
v-for="policy in agentCapacityPolicies"
:key="policy.id"
@@ -94,7 +94,8 @@ const createOption = (
key,
stateKey,
disabled = false,
disabledMessage = ''
disabledMessage = '',
disabledLabel = ''
) => ({
key,
label: t(`${BASE_KEY}.FORM.${type}.${key.toUpperCase()}.LABEL`),
@@ -102,6 +103,7 @@ const createOption = (
isActive: state[stateKey] === key,
disabled,
disabledMessage,
disabledLabel,
});
const assignmentOrderOptions = computed(() => {
@@ -116,13 +118,17 @@ const assignmentOrderOptions = computed(() => {
const disabledMessage = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_MESSAGE`)
: '';
const disabledLabel = disabled
? t(`${BASE_KEY}.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE`)
: '';
return createOption(
'ASSIGNMENT_ORDER',
key,
'assignmentOrder',
disabled,
disabledMessage
disabledMessage,
disabledLabel
);
});
});
@@ -217,6 +223,7 @@ defineExpose({
:description="option.description"
:is-active="option.isActive"
:disabled="option.disabled"
:disabled-label="option.disabledLabel"
:disabled-message="option.disabledMessage"
@select="state[section.key] = $event"
/>
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import EditAttribute from './EditAttribute.vue';
@@ -26,6 +27,7 @@ const inboxes = useMapGetter('inboxes/getInboxes');
const [showAddPopup, toggleAddPopup] = useToggle(false);
const selectedTabIndex = ref(0);
const searchQuery = ref('');
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
@@ -77,6 +79,7 @@ const attributes = computed(() =>
const onClickTabChange = tab => {
selectedTabIndex.value = tab.key;
searchQuery.value = '';
};
const handleEditAttribute = attribute => {
@@ -144,6 +147,16 @@ const derivedAttributes = computed(() =>
badges: buildBadges(attribute),
}))
);
const filteredAttributes = computed(() => {
const query = searchQuery.value.trim();
if (!query) return derivedAttributes.value;
return picoSearch(derivedAttributes.value, query, [
'attribute_display_name',
'attribute_key',
'attribute_description',
]);
});
</script>
<template>
@@ -153,31 +166,48 @@ const derivedAttributes = computed(() =>
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('ATTRIBUTES_MGMT.HEADER')"
:description="$t('ATTRIBUTES_MGMT.DESCRIPTION')"
:link-text="$t('ATTRIBUTES_MGMT.LEARN_MORE')"
:search-placeholder="$t('ATTRIBUTES_MGMT.SEARCH_PLACEHOLDER')"
feature-name="custom_attributes"
>
<template v-if="attributes?.length" #count>
<span class="text-body-main text-n-slate-11 truncate min-w-0">
{{ $t('ATTRIBUTES_MGMT.COUNT', { n: attributes.length }) }}
</span>
</template>
<template #tabs>
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
@tab-changed="onClickTabChange"
/>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('ATTRIBUTES_MGMT.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col gap-6">
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div v-if="derivedAttributes.length" class="grid gap-3">
<div class="flex flex-col gap-4">
<span
v-if="!filteredAttributes.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('ATTRIBUTES_MGMT.NO_RESULTS') }}
</span>
<div
v-else-if="filteredAttributes.length"
class="flex flex-col divide-y divide-n-weak border-t border-n-weak"
>
<AttributeListItem
v-for="attribute in derivedAttributes"
v-for="attribute in filteredAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"
@@ -2,8 +2,14 @@
import { useAlert } from 'dashboard/composables';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import {
generateTranslationPayload,
generateLogActionKey,
@@ -75,63 +81,68 @@ const tableHeaders = computed(() => {
</script>
<template>
<div class="flex-1 overflow-auto">
<BaseSettingsHeader
:title="$t('AUDIT_LOGS.HEADER')"
:description="$t('AUDIT_LOGS.DESCRIPTION')"
:link-text="$t('AUDIT_LOGS.LEARN_MORE')"
feature-name="audit_logs"
/>
<div class="mt-6 flex-1 text-n-slate-11">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('AUDIT_LOGS.LOADING')"
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('AUDIT_LOGS.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('AUDIT_LOGS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
:title="$t('AUDIT_LOGS.HEADER')"
:description="$t('AUDIT_LOGS.DESCRIPTION')"
:link-text="$t('AUDIT_LOGS.LEARN_MORE')"
feature-name="audit_logs"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base p-8"
>
{{ $t('AUDIT_LOGS.LIST.404') }}
</p>
<div v-else class="min-w-full overflow-x-auto">
<table class="divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
</template>
<template #body>
<div class="flex flex-col">
<BaseTable :headers="tableHeaders" :items="records">
<template #row="{ items }">
<BaseTableRow
v-for="auditLogItem in items"
:key="auditLogItem.id"
:item="auditLogItem"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{ generateLogText(auditLogItem) }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 break-all whitespace-nowrap">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td>
<td class="py-4 w-[8.75rem]">
{{ auditLogItem.remote_address }}
</td>
</tr>
</tbody>
</table>
<TableFooter
<template #default>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 whitespace-nowrap"
>
{{ generateLogText(auditLogItem) }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-11 whitespace-nowrap"
>
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</span>
</BaseTableCell>
<BaseTableCell class="w-36">
<span class="text-body-main text-n-slate-11">
{{ auditLogItem.remote_address }}
</span>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<PaginationFooter
:current-page="Number(meta.currentPage)"
:total-count="meta.totalEntries"
:page-size="meta.perPage"
class="border-n-weak border-t !px-0 py-4"
@page-change="onPageChange"
:total-items="meta.totalEntries"
:items-per-page="meta.perPage"
class="!px-0"
@update:current-page="onPageChange"
/>
</div>
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
const props = defineProps({
automation: {
@@ -35,47 +36,58 @@ const automationActive = computed({
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4 min-w-[200px]">{{ automation.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ automation.description }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<ToggleSwitch v-model="automationActive" />
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 min-w-[12px]"
:title="readableDateWithTime(automation.created_on)"
>
{{ readableDate(automation.created_on) }}
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-lucide-copy-plus"
xs
faded
:is-loading="loading"
@click="$emit('clone', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', automation)"
/>
</div>
</td>
</tr>
<BaseTableRow :item="automation">
<template #default>
<BaseTableCell class="max-w-0 w-full">
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ automation.name }}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ automation.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell>
<ToggleSwitch v-model="automationActive" />
</BaseTableCell>
<BaseTableCell :title="readableDateWithTime(automation.created_on)">
<span class="text-body-main text-n-slate-12 whitespace-nowrap">
{{ readableDate(automation.created_on) }}
</span>
</BaseTableCell>
<BaseTableCell align="end">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('AUTOMATION.FORM.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-woot-clone"
sm
slate
:is-loading="loading"
@click="$emit('clone', automation)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -7,8 +7,10 @@ import SettingsLayout from '../SettingsLayout.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import AutomationRuleRow from './AutomationRuleRow.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
const getters = useStoreGetters();
const store = useStore();
@@ -20,12 +22,19 @@ const addDialogRef = ref(null);
const editDialogRef = ref(null);
const showDeleteConfirmationPopup = ref(false);
const selectedAutomation = ref({});
const searchQuery = ref('');
const toggleModalTitle = ref(t('AUTOMATION.TOGGLE.ACTIVATION_TITLE'));
const toggleModalDescription = ref(
t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION')
);
const records = computed(() => getters['automations/getAutomations'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
const uiFlags = computed(() => getters['automations/getUIFlags'].value);
const accountId = computed(() => getters.getCurrentAccountId.value);
@@ -165,9 +174,9 @@ const toggleAutomation = async ({ id, name, status }) => {
const tableHeaders = computed(() => {
return [
t('AUTOMATION.LIST.TABLE_HEADER.NAME'),
t('AUTOMATION.LIST.TABLE_HEADER.DESCRIPTION'),
t('AUTOMATION.LIST.TABLE_HEADER.ACTIVE'),
t('AUTOMATION.LIST.TABLE_HEADER.CREATED_ON'),
t('AUTOMATION.LIST.TABLE_HEADER.ACTIONS'),
];
});
</script>
@@ -181,34 +190,38 @@ const tableHeaders = computed(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('AUTOMATION.HEADER')"
:description="$t('AUTOMATION.DESCRIPTION')"
:link-text="$t('AUTOMATION.LEARN_MORE')"
:search-placeholder="$t('AUTOMATION.SEARCH_PLACEHOLDER')"
feature-name="automation"
>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('AUTOMATION.COUNT', { n: records.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('AUTOMATION.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 rtl:text-right ltr:text-left font-semibold text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
"
>
<template #row="{ items }">
<AutomationRuleRow
v-for="automation in records"
v-for="automation in items"
:key="automation.id"
:automation="automation"
:loading="loading[automation.id]"
@@ -217,8 +230,8 @@ const tableHeaders = computed(() => {
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
</BaseTable>
</template>
<AddAutomationRule ref="addDialogRef" @save-automation="submitAutomation" />
@@ -2,13 +2,21 @@
import { useAlert } from 'dashboard/composables';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, onMounted, ref, defineOptions } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
defineOptions({
name: 'CannedResponseSettings',
@@ -28,9 +36,17 @@ const activeResponse = ref({});
const cannedResponseAPI = ref({ message: '' });
const sortOrder = ref('asc');
const searchQuery = ref('');
const records = computed(() =>
getters.getSortedCannedResponses.value(sortOrder.value)
);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['short_code', 'content']);
});
const uiFlags = computed(() => getters.getUIFlags.value);
const deleteConfirmText = computed(
@@ -114,103 +130,118 @@ const confirmDeletion = () => {
const tableHeaders = computed(() => {
return [
t('CANNED_MGMT.LIST.TABLE_HEADER.SHORT_CODE'),
t('CANNED_MGMT.LIST.TABLE_HEADER.CONTENT'),
t('CANNED_MGMT.LIST.TABLE_HEADER.ACTIONS'),
];
});
</script>
<template>
<div class="flex-1 overflow-auto">
<BaseSettingsHeader
:title="$t('CANNED_MGMT.HEADER')"
:description="$t('CANNED_MGMT.DESCRIPTION')"
:link-text="$t('CANNED_MGMT.LEARN_MORE')"
feature-name="canned_responses"
>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
<div class="mt-6 flex-1">
<woot-loading-state
v-if="uiFlags.fetchingList"
:message="$t('CANNED_MGMT.LOADING')"
/>
<p
v-else-if="!records.length"
class="flex flex-col items-center justify-center h-full text-base text-n-slate-11 py-8"
<SettingsLayout
:is-loading="uiFlags.fetchingList"
:loading-message="$t('CANNED_MGMT.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('CANNED_MGMT.LIST.404')"
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('CANNED_MGMT.HEADER')"
:description="$t('CANNED_MGMT.DESCRIPTION')"
:link-text="$t('CANNED_MGMT.LEARN_MORE')"
:search-placeholder="$t('CANNED_MGMT.SEARCH_PLACEHOLDER')"
feature-name="canned_responses"
>
{{ $t('CANNED_MGMT.LIST.404') }}
</p>
<table v-else class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11 last:text-right"
>
<span v-if="thHeader !== tableHeaders[0]">
{{ thHeader }}
</span>
<button
v-else
class="flex items-center p-0 cursor-pointer"
@click="toggleSort"
>
<span class="mb-0">
{{ thHeader }}
</span>
<fluent-icon
class="ml-2 size-4"
:icon="sortOrder === 'desc' ? 'chevron-up' : 'chevron-down'"
/>
</button>
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr
v-for="(cannedItem, index) in records"
:key="cannedItem.short_code"
>
<td
class="py-4 ltr:pr-4 rtl:pl-4 truncate max-w-xs font-medium"
:title="cannedItem.short_code"
>
{{ cannedItem.short_code }}
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4 md:break-all whitespace-normal">
{{ getPlainText(cannedItem.content) }}
</td>
<td class="py-4 flex justify-end gap-1">
<Button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="openEditPopup(cannedItem)"
/>
<Button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem, index)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('CANNED_MGMT.COUNT', { n: records.length }) }}
</span>
</template>
<template #actions>
<Button
:label="$t('CANNED_MGMT.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
!records.length
? $t('CANNED_MGMT.LIST.404')
: searchQuery
? $t('CANNED_MGMT.NO_RESULTS')
: ''
"
>
<template #header-0>
<button
class="flex items-center gap-2 p-0 cursor-pointer"
@click="toggleSort"
>
<span class="mb-0">
{{ tableHeaders[0] }}
</span>
<Icon
class="size-5 text-n-slate-11 flex-shrink-0"
:icon="
sortOrder === 'desc'
? 'i-woot-sort-descending'
: 'i-woot-sort-ascending'
"
/>
</button>
</template>
<template #header-1>
{{ tableHeaders[1] }}
</template>
<template #row="{ items }">
<BaseTableRow
v-for="cannedItem in items"
:key="cannedItem.short_code"
:item="cannedItem"
>
<template #default>
<BaseTableCell class="max-w-0">
<div class="flex flex-col gap-2 min-w-0">
<span class="text-heading-3 text-n-slate-12 truncate block">
{{ cannedItem.short_code }}
</span>
<p class="text-body-main text-n-slate-11 line-clamp-5">
{{ getPlainText(cannedItem.content) }}
</p>
</div>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('CANNED_MGMT.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="openEditPopup(cannedItem)"
/>
<Button
v-tooltip.top="$t('CANNED_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<AddCanned :on-close="hideAddPopup" />
</woot-modal>
@@ -235,5 +266,5 @@ const tableHeaders = computed(() => {
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
/>
</div>
</SettingsLayout>
</template>
@@ -1,9 +1,10 @@
<script setup>
import { useSlots } from 'vue';
import CustomBrandPolicyWrapper from 'dashboard/components/CustomBrandPolicyWrapper.vue';
import { getHelpUrlForFeature } from '../../../../helper/featureHelper';
import BackButton from '../../../../components/widgets/BackButton.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
title: {
@@ -11,10 +12,6 @@ const props = defineProps({
required: true,
},
description: {
type: String,
required: true,
},
iconName: {
type: String,
default: '',
},
@@ -30,52 +27,42 @@ const props = defineProps({
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
});
const helpURL = getHelpUrlForFeature(props.featureName);
const slots = useSlots();
const openInNewTab = url => {
if (!url) return;
window.open(url, '_blank', 'noopener noreferrer');
};
const searchQuery = defineModel('searchQuery', { type: String, default: '' });
const helpURL = getHelpUrlForFeature(props.featureName);
</script>
<template>
<div class="flex flex-col items-start w-full gap-2">
<div class="flex flex-col items-start w-full">
<BackButton
v-if="backButtonLabel"
compact
:button-label="backButtonLabel"
class="my-1"
/>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3">
<div
v-if="iconName"
class="flex items-center w-10 h-10 p-1 rounded-full bg-n-blue-2"
>
<div
class="flex items-center justify-center w-full h-full rounded-full bg-n-blue-3"
>
<fluent-icon
size="14"
:icon="iconName"
type="outline"
class="flex-shrink-0 text-n-brand"
/>
</div>
</div>
<h1 class="text-xl font-medium tracking-tight text-n-slate-12">
{{ title }}
</h1>
</div>
<!-- Slot for additional actions on larger screens -->
<div class="hidden gap-2 sm:flex">
<slot name="actions" />
</div>
<div
v-if="title"
class="flex items-center justify-between w-full gap-4 min-h-8 mb-2"
>
<h1 class="text-heading-1 text-n-slate-12">
{{ title }}
</h1>
</div>
<div class="flex flex-col w-full gap-3 text-n-slate-11">
<div
v-if="description || $slots.description || linkText || helpURL"
class="flex flex-col w-full gap-1.5 text-n-slate-11"
>
<p
class="mb-0 text-sm font-normal line-clamp-5 sm:line-clamp-none max-w-3xl"
v-if="description || $slots.description"
class="mb-0 line-clamp-5 sm:line-clamp-none max-w-3xl text-body-main"
>
<slot name="description">{{ description }}</slot>
</p>
@@ -85,7 +72,7 @@ const openInNewTab = url => {
:href="helpURL"
target="_blank"
rel="noopener noreferrer"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-blue-11 hover:underline"
class="items-center hidden gap-1 text-sm font-medium sm:inline-flex w-fit text-n-blue-11 hover:underline mb-2"
>
{{ linkText }}
<Icon
@@ -95,21 +82,45 @@ const openInNewTab = url => {
</a>
</CustomBrandPolicyWrapper>
</div>
</div>
<div
v-if="searchPlaceholder || slots.actions || slots.tabs"
class="gap-3 flex justify-between sm:mt-4 min-w-0"
>
<div
class="flex flex-wrap items-start justify-start w-full gap-3 sm:hidden"
v-if="slots.tabs || searchPlaceholder"
class="flex items-center gap-3"
:class="{
'hidden sm:flex': !slots.tabs,
}"
>
<slot name="tabs" />
<Input
v-if="searchPlaceholder"
v-model="searchQuery"
:placeholder="searchPlaceholder"
class="group w-56 min-w-0 hidden sm:flex [&>input]:ltr:!pl-8 [&>input]:rtl:!pr-8 [&>input]:!rounded-[0.625rem]"
size="sm"
type="search"
>
<template #prefix>
<Icon
icon="i-lucide-search"
class="absolute top-1/2 -translate-y-1/2 text-n-slate-11 group-focus-within:text-n-brand size-3.5 ltr:left-2.5 rtl:right-2.5"
/>
</template>
</Input>
</div>
<div
class="flex items-center gap-3 min-w-0"
:class="{ 'flex-row-reverse sm:flex-row': !slots.tabs }"
>
<slot name="count" />
<div
v-if="slots.count"
class="w-px h-3 rounded-lg bg-n-weak ltr:ml-1 ltr:mr-2 rtl:ml-2 rtl:mr-1 flex-shrink-0"
/>
<slot name="actions" />
<CustomBrandPolicyWrapper :show-on-custom-branded-instance="false">
<Button
v-if="helpURL && linkText"
blue
link
icon="i-lucide-chevron-right"
trailing-icon
:label="linkText"
@click="openInNewTab(helpURL)"
/>
</CustomBrandPolicyWrapper>
</div>
</div>
</template>
@@ -39,7 +39,7 @@ const showRequiredAttributes = computed(() => {
</template>
<template #body>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6 mt-4">
<AutoResolve v-if="showAutoResolutionConfig" />
<ConversationRequiredAttributes :is-enabled="showRequiredAttributes" />
</div>
@@ -9,6 +9,8 @@ import Button from 'dashboard/components-next/button/Button.vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import { BaseTable } from 'dashboard/components-next/table';
const store = useStore();
const { t } = useI18n();
@@ -19,8 +21,15 @@ const selectedRole = ref(null);
const loading = ref({});
const showDeleteConfirmationPopup = ref(false);
const activeResponse = ref({});
const searchQuery = ref('');
const records = useMapGetter('customRole/getCustomRoles');
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['name', 'description']);
});
const uiFlags = useMapGetter('customRole/getUIFlags');
const deleteConfirmText = computed(
@@ -129,15 +138,22 @@ const confirmDeletion = () => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('CUSTOM_ROLE.HEADER')"
:description="$t('CUSTOM_ROLE.DESCRIPTION')"
:link-text="$t('CUSTOM_ROLE.LEARN_MORE')"
:search-placeholder="$t('CUSTOM_ROLE.SEARCH_PLACEHOLDER')"
feature-name="canned_responses"
>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('CUSTOM_ROLE.COUNT', { n: records.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('CUSTOM_ROLE.HEADER_BTN_TXT')"
size="sm"
:disabled="isBehindAPaywall"
@click="openAddModal"
/>
@@ -147,26 +163,25 @@ const confirmDeletion = () => {
<template #body>
<CustomRolePaywall v-if="isBehindAPaywall" />
<table v-else class="min-w-full overflow-x-auto divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-n-slate-11"
>
<span class="mb-0">
{{ thHeader }}
</span>
</th>
</thead>
<CustomRoleTableBody
:roles="records"
:loading="loading"
@edit="openEditModal"
@delete="openDeletePopup"
/>
</table>
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery
? $t('CUSTOM_ROLE.NO_RESULTS')
: $t('CUSTOM_ROLE.LIST.404')
"
>
<template #row="{ items }">
<CustomRoleTableBody
:roles="items"
:loading="loading"
@edit="openEditModal"
@delete="openDeletePopup"
/>
</template>
</BaseTable>
</template>
<woot-modal
@@ -79,7 +79,7 @@ const tableHeaders = computed(() => {
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-n-slate-11"
class="py-4 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12"
>
<span class="mb-0">
{{ thHeader }}
@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
defineProps({
roles: {
@@ -27,43 +28,49 @@ const getFormattedPermissions = role => {
</script>
<template>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<tr v-for="(customRole, index) in roles" :key="index">
<td
class="max-w-xs py-4 ltr:pr-4 rtl:pl-4 font-medium truncate align-baseline"
:title="customRole.name"
>
{{ customRole.name }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 whitespace-normal align-baseline md:break-words"
>
{{ customRole.description }}
</td>
<td
class="py-4 ltr:pr-4 rtl:pl-4 whitespace-normal align-baseline md:break-words"
>
{{ getFormattedPermissions(customRole) }}
</td>
<td class="flex justify-end gap-1 py-4">
<Button
v-tooltip.top="$t('CUSTOM_ROLE.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="emit('edit', customRole)"
/>
<Button
v-tooltip.top="$t('CUSTOM_ROLE.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[customRole.id]"
@click="emit('delete', customRole)"
/>
</td>
</tr>
</tbody>
<BaseTableRow
v-for="customRole in roles"
:key="customRole.id"
:item="customRole"
>
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12 truncate block">
{{ customRole.name }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11 truncate block">
{{ customRole.description }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11 block">
{{ getFormattedPermissions(customRole) }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('CUSTOM_ROLE.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', customRole)"
/>
<Button
v-tooltip.top="$t('CUSTOM_ROLE.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[customRole.id]"
@click="emit('delete', customRole)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -92,7 +92,7 @@ const channelList = computed(() => {
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-ri-phone-fill',
icon: 'i-woot-voice',
});
return channels;
@@ -1,14 +1,14 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsSection,
SettingsFieldSection,
NextButton,
},
props: {
@@ -95,75 +95,73 @@ export default {
</script>
<template>
<div class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.IMAP.TITLE')"
:sub-title="$t('INBOX_MGMT.IMAP.SUBTITLE')"
:note="$t('INBOX_MGMT.IMAP.NOTE_TEXT')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-imap-enable">
<SettingsFieldSection
:label="$t('INBOX_MGMT.IMAP.TITLE')"
:help-text="$t('INBOX_MGMT.IMAP.NOTE_TEXT')"
class="[&>div]:!items-start [&>div>label]:mt-1 mb-4"
>
<form @submit.prevent="updateInbox">
<label for="toggle-imap-enable">
<input
v-model="isIMAPEnabled"
type="checkbox"
class="ltr:mr-1 rtl:ml-1"
name="toggle-imap-enable"
/>
{{ $t('INBOX_MGMT.IMAP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.IMAP.TOGGLE_HELP') }}</p>
<div v-if="isIMAPEnabled" class="mb-6">
<woot-input
v-model="address"
:class="{ error: v$.address.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
@blur="v$.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: v$.port.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
@blur="v$.port.$touch"
/>
<woot-input
v-model="login"
:class="{ error: v$.login.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
@blur="v$.login.$touch"
/>
<woot-input
v-model="password"
:class="{ error: v$.password.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="v$.password.$touch"
/>
<label for="toggle-enable-ssl">
<input
v-model="isIMAPEnabled"
v-model="isSSLEnabled"
type="checkbox"
class="ltr:mr-2 rtl:ml-2"
name="toggle-imap-enable"
name="toggle-enable-ssl"
/>
{{ $t('INBOX_MGMT.IMAP.TOGGLE_AVAILABILITY') }}
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
</label>
<p>{{ $t('INBOX_MGMT.IMAP.TOGGLE_HELP') }}</p>
<div v-if="isIMAPEnabled" class="mb-6">
<woot-input
v-model="address"
:class="{ error: v$.address.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
@blur="v$.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: v$.port.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
@blur="v$.port.$touch"
/>
<woot-input
v-model="login"
:class="{ error: v$.login.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
@blur="v$.login.$touch"
/>
<woot-input
v-model="password"
:class="{ error: v$.password.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="v$.password.$touch"
/>
<label for="toggle-enable-ssl">
<input
v-model="isSSLEnabled"
type="checkbox"
class="ltr:mr-2 rtl:ml-2"
name="toggle-enable-ssl"
/>
{{ $t('INBOX_MGMT.IMAP.ENABLE_SSL') }}
</label>
</div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.IMAP.UPDATE')"
:is-loading="uiFlags.isUpdatingIMAP"
:disabled="(v$.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
/>
</form>
</SettingsSection>
</div>
</div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.IMAP.UPDATE')"
:is-loading="uiFlags.isUpdatingIMAP"
:disabled="(v$.$invalid && isIMAPEnabled) || uiFlags.isUpdatingIMAP"
/>
</form>
</SettingsFieldSection>
</template>
@@ -59,7 +59,7 @@ const items = computed(() => {
</script>
<template>
<div class="mx-2 flex flex-col gap-6 mb-8">
<div class="mx-auto flex flex-col gap-6 mb-8 max-w-7xl w-full !px-6">
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
@@ -2,6 +2,7 @@
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import Avatar from 'next/avatar/Avatar.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import SettingsLayout from '../SettingsLayout.vue';
@@ -22,6 +23,7 @@ const { isAdmin } = useAdmin();
const showDeletePopup = ref(false);
const selectedInbox = ref({});
const searchQuery = ref('');
const inboxes = useMapGetter('inboxes/getInboxes');
@@ -29,6 +31,12 @@ const inboxesList = computed(() => {
return inboxes.value?.slice().sort((a, b) => a.name.localeCompare(b.name));
});
const filteredInboxesList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return inboxesList.value;
return picoSearch(inboxesList.value, query, ['name', 'channel_type']);
});
const uiFlags = computed(() => getters['inboxes/getUIFlags'].value);
const deleteConfirmText = computed(
@@ -80,87 +88,93 @@ const openDelete = inbox => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('INBOX_MGMT.HEADER')"
:description="$t('INBOX_MGMT.DESCRIPTION')"
:link-text="$t('INBOX_MGMT.LEARN_MORE')"
:search-placeholder="$t('INBOX_MGMT.SEARCH_PLACEHOLDER')"
feature-name="inboxes"
>
<template v-if="inboxesList?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.COUNT', { n: inboxesList.length }) }}
</span>
</template>
<template #actions>
<router-link v-if="isAdmin" :to="{ name: 'settings_inbox_new' }">
<Button
icon="i-lucide-circle-plus"
:label="$t('SETTINGS.INBOXES.NEW_INBOX')"
/>
<Button :label="$t('SETTINGS.INBOXES.NEW_INBOX')" size="sm" />
</router-link>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full overflow-x-auto">
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-full size-12 p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<Avatar
:src="inbox.avatar_url"
:name="inbox.name"
:size="30"
rounded-full
/>
</div>
<div
v-else
class="size-12 flex justify-center items-center bg-n-alpha-3 rounded-full p-2 ring ring-n-solid-1 border border-n-strong shadow-sm"
>
<ChannelIcon class="size-5 text-n-slate-10" :inbox="inbox" />
</div>
<div>
<span class="block font-medium capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
/>
</div>
</div>
</td>
<td class="py-4">
<div class="flex gap-1 justify-end">
<router-link
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
icon="i-lucide-settings"
slate
xs
faded
/>
</router-link>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="openDelete(inbox)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<span
v-if="!filteredInboxesList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('INBOX_MGMT.NO_RESULTS') }}
</span>
<div v-else class="divide-y divide-n-weak border-t border-n-weak">
<div
v-for="inbox in filteredInboxesList"
:key="inbox.id"
class="flex justify-between flex-row items-start gap-4 py-4"
>
<div class="flex items-center gap-4">
<div
v-if="inbox.avatar_url"
class="bg-n-alpha-3 rounded-xl size-10 ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<Avatar
:src="inbox.avatar_url"
:name="inbox.name"
:size="24"
rounded-full
/>
</div>
<div
v-else
class="size-10 justify-center bg-n-alpha-3 rounded-xl ring ring-n-solid-1 border border-n-strong shadow-sm grid place-items-center"
>
<ChannelIcon class="size-6 text-n-slate-10" :inbox="inbox" />
</div>
<div class="flex flex-col items-start gap-1">
<span class="block text-heading-3 text-n-slate-12 capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
class="text-body-main text-n-slate-11"
/>
</div>
</div>
<div class="flex gap-3 justify-end">
<router-link
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
icon="i-woot-settings"
slate
sm
/>
</router-link>
<Button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
icon="i-woot-bin"
slate
sm
@click="openDelete(inbox)"
/>
</div>
</div>
</div>
</template>
<woot-confirm-delete-modal
@@ -1,38 +1,38 @@
<script>
<script setup>
import { ref, watch } from 'vue';
import Draggable from 'vuedraggable';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
export default {
components: { Draggable, ToggleSwitch },
props: {
preChatFields: {
type: Array,
default: () => [],
},
},
emits: ['update', 'dragEnd'],
data() {
return {
preChatFieldOptions: this.preChatFields,
};
},
watch: {
preChatFields() {
this.preChatFieldOptions = this.preChatFields;
},
},
methods: {
isFieldEditable(item) {
return !item.enabled;
},
handlePreChatFieldOptions(event, type, item) {
this.$emit('update', event, type, item);
},
onDragEnd() {
this.$emit('dragEnd', this.preChatFieldOptions);
},
const props = defineProps({
preChatFields: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update', 'dragEnd']);
const preChatFieldOptions = ref(props.preChatFields);
const isFieldEditable = item => {
return !item.enabled;
};
const handlePreChatFieldOptions = (event, type, item) => {
emit('update', event, type, item);
};
const onDragEnd = () => {
emit('dragEnd', preChatFieldOptions.value);
};
watch(
() => props.preChatFields,
newFields => {
preChatFieldOptions.value = newFields;
}
);
</script>
<template>
@@ -43,81 +43,64 @@ export default {
@end="onDragEnd"
>
<template #item="{ element: item }">
<tr class="border-b border-n-weak">
<td class="pre-chat-field"><fluent-icon icon="drag" /></td>
<td class="pre-chat-field">
<tr>
<td class="py-4 ltr:pl-4 ltr:pr-3 rtl:pl-3 rtl:pr-4 text-body-main">
<Icon
icon="i-woot-drag-indicator"
class="size-4 text-n-slate-11 mt-1 cursor-move"
/>
</td>
<td class="py-4 ltr:pr-3 rtl:pl-3 text-body-main">
<ToggleSwitch
:model-value="item['enabled']"
@change="handlePreChatFieldOptions($event, 'enabled', item)"
/>
</td>
<td
class="pre-chat-field"
:class="{ 'disabled-text': !item['enabled'] }"
class="py-4 ltr:pr-3 rtl:pl-3 text-body-main"
:class="{ 'text-n-slate-11': !item['enabled'] }"
>
{{ item.name }}
</td>
<td
class="pre-chat-field"
:class="{ 'disabled-text': !item['enabled'] }"
class="py-4 ltr:pr-3 rtl:pl-3 text-body-main"
:class="{ 'text-n-slate-11': !item['enabled'] }"
>
{{ item.type }}
</td>
<td class="pre-chat-field">
<td class="py-4 ltr:pr-3 rtl:pl-3 text-body-main">
<input
v-model="item['required']"
type="checkbox"
:value="`${item.name}-required`"
:disabled="!item['enabled']"
class="m-0"
@click="handlePreChatFieldOptions($event, 'required', item)"
/>
</td>
<td
class="pre-chat-field"
:class="{ 'disabled-text': !item['enabled'] }"
class="py-4 ltr:pr-3 rtl:pl-3 text-body-main"
:class="{ 'text-n-slate-11': !item['enabled'] }"
>
<input
v-model="item.label"
type="text"
:disabled="isFieldEditable(item)"
class="w-full text-sm !mb-0 px-2 py-1 border border-n-weak rounded"
/>
</td>
<td
class="pre-chat-field"
:class="{ 'disabled-text': !item['enabled'] }"
class="py-4 ltr:pr-4 rtl:pl-4 text-body-main"
:class="{ 'text-n-slate-11': !item['enabled'] }"
>
<input
v-model="item.placeholder"
type="text"
:disabled="isFieldEditable(item)"
class="w-full text-sm !mb-0 px-2 py-1 border border-n-weak rounded"
/>
</td>
</tr>
</template>
</Draggable>
</template>
<style scoped lang="scss">
.pre-chat-field {
@apply py-4 px-2 text-n-slate-12;
svg {
@apply flex items-center;
}
}
.disabled-text {
@apply text-n-slate-11;
}
table {
thead th {
@apply normal-case;
}
input {
@apply text-sm mb-0;
}
}
checkbox {
@apply m-0;
}
</style>
@@ -1,159 +1,135 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import PreChatFields from './PreChatFields.vue';
import { getPreChatFields, standardFieldKeys } from 'dashboard/helper/preChat';
import { getPreChatFields } from 'dashboard/helper/preChat';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
export default {
components: {
PreChatFields,
WootMessageEditor,
NextButton,
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
preChatFormEnabled: false,
preChatMessage: '',
preChatFields: [],
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
customAttributes: 'attributes/getAttributes',
}),
preChatFieldOptions() {
const { pre_chat_form_options: preChatFormOptions } = this.inbox;
return getPreChatFields({
preChatFormOptions,
customAttributes: this.customAttributes,
});
},
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
const { pre_chat_form_enabled: preChatFormEnabled } = this.inbox;
this.preChatFormEnabled = preChatFormEnabled;
const {
pre_chat_message: preChatMessage,
pre_chat_fields: preChatFields,
} = this.preChatFieldOptions || {};
this.preChatMessage = preChatMessage;
this.preChatFields = preChatFields;
},
isFieldEditable(item) {
return !!standardFieldKeys[item.name] || !item.enabled;
},
handlePreChatFieldOptions(event, type, item) {
this.preChatFields.forEach((field, index) => {
if (field.name === item.name) {
this.preChatFields[index][type] = !item[type];
}
});
},
});
changePreChatFieldFieldsOrder(updatedPreChatFieldOptions) {
this.preChatFields = updatedPreChatFieldOptions;
},
const { t } = useI18n();
const store = useStore();
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
channel: {
pre_chat_form_enabled: this.preChatFormEnabled,
pre_chat_form_options: {
pre_chat_message: this.preChatMessage,
pre_chat_fields: this.preChatFields,
},
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
}
},
},
const uiFlags = useMapGetter('inboxes/getUIFlags');
const customAttributes = useMapGetter('attributes/getAttributes');
const preChatFormEnabled = ref(false);
const preChatMessage = ref('');
const preChatFields = ref([]);
const preChatFieldOptions = computed(() => {
const { pre_chat_form_options: preChatFormOptions } = props.inbox;
return getPreChatFields({
preChatFormOptions,
customAttributes: customAttributes.value,
});
});
const tableHeaders = computed(() => [
'',
'',
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.KEY'),
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.TYPE'),
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.REQUIRED'),
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.LABEL'),
t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.PLACE_HOLDER'),
]);
const setDefaults = () => {
const { pre_chat_form_enabled: formEnabled } = props.inbox;
preChatFormEnabled.value = formEnabled;
const { pre_chat_message: message, pre_chat_fields: fields } =
preChatFieldOptions.value || {};
preChatMessage.value = message;
preChatFields.value = fields;
};
const handlePreChatFieldOptions = (event, type, item) => {
preChatFields.value.forEach((field, index) => {
if (field.name === item.name) {
preChatFields.value[index][type] = !item[type];
}
});
};
const changePreChatFieldFieldsOrder = updatedPreChatFieldOptions => {
preChatFields.value = updatedPreChatFieldOptions;
};
const updateInbox = async () => {
try {
const payload = {
id: props.inbox.id,
formData: false,
channel: {
pre_chat_form_enabled: preChatFormEnabled.value,
pre_chat_form_options: {
pre_chat_message: preChatMessage.value,
pre_chat_fields: preChatFields.value,
},
},
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
};
watch(() => props.inbox, setDefaults);
onMounted(() => {
setDefaults();
});
</script>
<template>
<div class="mx-8 my-2 text-base">
<div class="mx-0 mt-6 mb-3">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.DESCRIPTION') }}
<div class="mx-6">
<SettingsToggleSection
v-model="preChatFormEnabled"
:header="$t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.LABEL')"
:description="$t('INBOX_MGMT.PRE_CHAT_FORM.DESCRIPTION')"
>
<template v-if="preChatFormEnabled" #editor>
<WootMessageEditor
v-model="preChatMessage"
:placeholder="
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
"
/>
</template>
</SettingsToggleSection>
<div v-if="preChatFormEnabled" class="flex items-center my-8 py-1">
<div class="flex-1 h-px bg-n-weak" />
<span class="text-body-main text-n-slate-11 px-2">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}
</span>
<div class="flex-1 h-px bg-n-weak" />
</div>
<form class="flex flex-col" @submit.prevent="updateInbox">
<label class="w-1/4">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.LABEL') }}
<select v-model="preChatFormEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.OPTIONS.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.OPTIONS.DISABLED') }}
</option>
</select>
</label>
<div v-if="preChatFormEnabled">
<div>
<label>
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
</label>
<WootMessageEditor
v-model="preChatMessage"
class="message-editor"
:placeholder="
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
"
/>
</div>
<div class="mt-4">
<label>{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}</label>
<table class="table w-full table-striped mt-4">
<thead class="thead-dark">
<tr
class="[&>th]:font-semibold [&>th]:tracking-[1px] ltr:[&>th]:text-left rtl:[&>th]:text-right [&>th]:px-2.5 [&>th]:uppercase [&>th]:text-n-slate-12"
>
<th scope="col" />
<th scope="col" />
<th scope="col">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.KEY') }}
</th>
<th scope="col">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.TYPE') }}
</th>
<th scope="col">
{{
$t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.REQUIRED')
}}
</th>
<th scope="col">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.LABEL') }}
</th>
<th scope="col">
{{
$t(
'INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.PLACE_HOLDER'
)
}}
<div class="w-full">
<table
class="min-w-full table-auto outline outline-1 -outline-offset-1 outline-n-weak rounded-xl"
>
<thead>
<tr class="border-b border-n-weak">
<th
v-for="(header, index) in tableHeaders"
:key="index"
class="py-3 ltr:pr-4 rtl:pl-4 text-start text-heading-3 text-n-slate-12"
>
{{ header }}
</th>
</tr>
</thead>
@@ -165,8 +141,8 @@ export default {
</table>
</div>
</div>
<div class="w-auto my-4">
<NextButton
<div class="w-full flex justify-end items-center py-4 mt-2">
<Button
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE_PRE_CHAT_FORM_SETTINGS')"
:is-loading="uiFlags.isUpdating"
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import InputRadioGroup from './components/InputRadioGroup.vue';
@@ -10,7 +10,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
SettingsSection,
SettingsFieldSection,
InputRadioGroup,
SingleSelectDropdown,
NextButton,
@@ -155,91 +155,91 @@ export default {
</script>
<template>
<div class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SMTP.TITLE')"
:sub-title="$t('INBOX_MGMT.SMTP.SUBTITLE')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-enable-smtp">
<input
v-model="isSMTPEnabled"
type="checkbox"
name="toggle-enable-smtp"
/>
{{ $t('INBOX_MGMT.SMTP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.SMTP.TOGGLE_HELP') }}</p>
<div v-if="isSMTPEnabled" class="mb-6">
<woot-input
v-model="address"
:class="{ error: v$.address.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
@blur="v$.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: v$.port.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
@blur="v$.port.$touch"
/>
<woot-input
v-model="login"
:class="{ error: v$.login.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
@blur="v$.login.$touch"
/>
<woot-input
v-model="password"
:class="{ error: v$.password.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="v$.password.$touch"
/>
<woot-input
v-model="domain"
:class="{ error: v$.domain.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
@blur="v$.domain.$touch"
/>
<InputRadioGroup
:label="$t('INBOX_MGMT.SMTP.ENCRYPTION')"
:items="encryptionProtocols"
:action="handleEncryptionChange"
/>
<SingleSelectDropdown
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
:selected="openSSLVerifyMode"
:options="openSSLVerifyModes"
:action="handleSSLModeChange"
/>
<SingleSelectDropdown
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
:selected="authMechanism"
:options="authMechanisms"
:action="handleAuthMechanismChange"
/>
</div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SMTP.UPDATE')"
:is-loading="uiFlags.isUpdatingSMTP"
:disabled="(v$.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
<SettingsFieldSection
:label="$t('INBOX_MGMT.SMTP.TITLE')"
:help-text="$t('INBOX_MGMT.SMTP.SUBTITLE')"
class="[&>div]:!items-start [&>div>label]:mt-1 mb-4"
>
<form @submit.prevent="updateInbox">
<label for="toggle-enable-smtp">
<input
v-model="isSMTPEnabled"
type="checkbox"
name="toggle-enable-smtp"
class="ltr:mr-1 rtl:ml-1"
/>
</form>
</SettingsSection>
</div>
{{ $t('INBOX_MGMT.SMTP.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.SMTP.TOGGLE_HELP') }}</p>
<div v-if="isSMTPEnabled" class="mb-6">
<woot-input
v-model="address"
:class="{ error: v$.address.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
@blur="v$.address.$touch"
/>
<woot-input
v-model="port"
type="number"
:class="{ error: v$.port.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
@blur="v$.port.$touch"
/>
<woot-input
v-model="login"
:class="{ error: v$.login.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
@blur="v$.login.$touch"
/>
<woot-input
v-model="password"
:class="{ error: v$.password.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
type="password"
@blur="v$.password.$touch"
/>
<woot-input
v-model="domain"
:class="{ error: v$.domain.$error }"
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
@blur="v$.domain.$touch"
/>
<InputRadioGroup
:label="$t('INBOX_MGMT.SMTP.ENCRYPTION')"
:items="encryptionProtocols"
:action="handleEncryptionChange"
/>
<SingleSelectDropdown
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
:selected="openSSLVerifyMode"
:options="openSSLVerifyModes"
:action="handleSSLModeChange"
/>
<SingleSelectDropdown
class="max-w-[75%] w-full"
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
:selected="authMechanism"
:options="authMechanisms"
:action="handleAuthMechanismChange"
/>
</div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SMTP.UPDATE')"
:is-loading="uiFlags.isUpdatingSMTP"
:disabled="(v$.$invalid && isSMTPEnabled) || uiFlags.isUpdatingSMTP"
/>
</form>
</SettingsFieldSection>
</template>
@@ -1,443 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import InputRadioGroup from './components/InputRadioGroup.vue';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Avatar from 'next/avatar/Avatar.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default {
components: {
Widget,
InputRadioGroup,
NextButton,
Editor,
Avatar,
},
props: {
inbox: {
type: Object,
default: () => {},
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
isWidgetPreview: true,
color: '#1f93ff',
websiteName: '',
welcomeHeading: '',
welcomeTagline: '',
replyTime: 'in_a_few_minutes',
avatarFile: null,
avatarUrl: '',
widgetBubblePosition: 'right',
widgetBubbleLauncherTitle: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.DEFAULT'
),
widgetBubbleType: 'standard',
widgetBubblePositions: [
{
id: 'left',
title: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.LEFT'
),
checked: false,
},
{
id: 'right',
title: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION.RIGHT'
),
checked: true,
},
],
widgetBubbleTypes: [
{
id: 'standard',
title: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.STANDARD'
),
checked: true,
},
{
id: 'expanded_bubble',
title: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE.EXPANDED_BUBBLE'
),
checked: false,
},
],
};
},
computed: {
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
}),
storageKey() {
return `${LOCAL_STORAGE_KEYS.WIDGET_BUILDER}${this.inbox.id}`;
},
widgetScript() {
let options = {
position: this.widgetBubblePosition,
type: this.widgetBubbleType,
launcherTitle: this.widgetBubbleLauncherTitle,
};
let script = this.inbox.web_widget_script;
return (
script.substring(0, 13) +
this.$t('INBOX_MGMT.WIDGET_BUILDER.SCRIPT_SETTINGS', {
options: JSON.stringify(options),
}) +
script.substring(13)
);
},
getWidgetViewOptions() {
return [
{
id: 'preview',
title: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.PREVIEW'
),
checked: true,
},
{
id: 'script',
title: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_VIEW_OPTION.SCRIPT'
),
checked: false,
},
];
},
getReplyTimeOptions() {
return [
{
key: 'in_a_few_minutes',
value: 'in_a_few_minutes',
text: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_MINUTES'
),
},
{
key: 'in_a_few_hours',
value: 'in_a_few_hours',
text: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_FEW_HOURS'
),
},
{
key: 'in_a_day',
value: 'in_a_day',
text: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.IN_A_DAY'
),
},
];
},
websiteNameValidationErrorMsg() {
return this.v$.websiteName.$error
? this.$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.ERROR')
: '';
},
},
mounted() {
this.setDefaults();
},
validations: {
websiteName: { required },
},
methods: {
setDefaults() {
// Widget Settings
const {
name,
welcome_title,
welcome_tagline,
widget_color,
reply_time,
avatar_url,
} = this.inbox;
this.websiteName = name;
this.welcomeHeading = welcome_title;
this.welcomeTagline = welcome_tagline;
this.color = widget_color;
this.replyTime = reply_time;
this.avatarUrl = avatar_url;
const savedInformation = this.getSavedInboxInformation();
if (savedInformation) {
this.widgetBubblePositions = this.widgetBubblePositions.map(item => {
if (item.id === savedInformation.position) {
item.checked = true;
this.widgetBubblePosition = item.id;
}
return item;
});
this.widgetBubbleTypes = this.widgetBubbleTypes.map(item => {
if (item.id === savedInformation.type) {
item.checked = true;
this.widgetBubbleType = item.id;
}
return item;
});
this.widgetBubbleLauncherTitle =
savedInformation.launcherTitle || 'Chat with us';
}
},
handleWidgetBubblePositionChange(item) {
this.widgetBubblePosition = item.id;
},
handleWidgetBubbleTypeChange(item) {
this.widgetBubbleType = item.id;
},
handleWidgetViewChange(item) {
this.isWidgetPreview = item.id === 'preview';
},
handleImageUpload({ file, url }) {
this.avatarFile = file;
this.avatarUrl = url;
},
async handleAvatarDelete() {
try {
await this.$store.dispatch('inboxes/deleteInboxAvatar', this.inbox.id);
this.avatarFile = null;
this.avatarUrl = '';
useAlert(
this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.SUCCESS_MESSAGE'
)
);
} catch (error) {
useAlert(
error.message
? error.message
: this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.DELETE.API.ERROR_MESSAGE'
)
);
}
},
async updateWidget() {
const bubbleSettings = {
position: this.widgetBubblePosition,
launcherTitle: this.widgetBubbleLauncherTitle,
type: this.widgetBubbleType,
};
LocalStorage.set(this.storageKey, bubbleSettings);
try {
const payload = {
id: this.inbox.id,
name: this.websiteName,
channel: {
widget_color: this.color,
welcome_title: this.welcomeHeading,
welcome_tagline: this.welcomeTagline,
reply_time: this.replyTime,
},
};
if (this.avatarFile) {
payload.avatar = this.avatarFile;
}
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(
this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.SUCCESS_MESSAGE'
)
);
} catch (error) {
useAlert(
error.message ||
this.$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.API.ERROR_MESSAGE'
)
);
}
},
getSavedInboxInformation() {
return LocalStorage.get(this.storageKey);
},
},
};
</script>
<template>
<div class="mx-8">
<div class="flex p-2.5">
<div class="w-100 lg:w-[40%]">
<div class="min-h-full py-4 overflow-y-scroll px-px">
<form @submit.prevent="updateWidget">
<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('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.AVATAR.LABEL')
}}
</label>
<Avatar
:src="avatarUrl"
:size="72"
icon-name="i-ri-global-fill"
name=""
allow-upload
rounded-full
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<woot-input
v-model="websiteName"
:class="{ error: v$.websiteName.$error }"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WEBSITE_NAME.PLACE_HOLDER'
)
"
:error="websiteNameValidationErrorMsg"
@blur="v$.websiteName.$touch"
/>
<woot-input
v-model="welcomeHeading"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_HEADING.PLACE_HOLDER'
)
"
/>
<Editor
v-model="welcomeTagline"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
)
"
:max-length="255"
channel-type="Context::InboxSettings"
class="mb-4"
/>
<label>
{{
$t('INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.REPLY_TIME.LABEL')
}}
<select v-model="replyTime">
<option
v-for="option in getReplyTimeOptions"
:key="option.key"
:value="option.value"
>
{{ option.text }}
</option>
</select>
</label>
<label>
{{
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_COLOR_LABEL'
)
}}
<woot-color-picker v-model="color" />
</label>
<InputRadioGroup
name="widget-bubble-position"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_POSITION_LABEL'
)
"
:items="widgetBubblePositions"
:action="handleWidgetBubblePositionChange"
/>
<InputRadioGroup
name="widget-bubble-type"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_TYPE_LABEL'
)
"
:items="widgetBubbleTypes"
:action="handleWidgetBubbleTypeChange"
/>
<woot-input
v-model="widgetBubbleLauncherTitle"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.LABEL'
)
"
:placeholder="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WIDGET_BUBBLE_LAUNCHER_TITLE.PLACE_HOLDER'
)
"
/>
<NextButton
type="submit"
class="mt-4"
:label="
$t(
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.UPDATE.BUTTON_TEXT'
)
"
:is-loading="uiFlags.isUpdating"
:disabled="v$.$invalid || uiFlags.isUpdating"
/>
</form>
</div>
</div>
<div class="w-100 lg:w-3/5">
<InputRadioGroup
name="widget-view-options"
class="text-center"
:items="getWidgetViewOptions"
:action="handleWidgetViewChange"
/>
<div
v-if="isWidgetPreview"
class="flex flex-col items-center justify-end min-h-[40.625rem] mx-5 mb-5 p-2.5 bg-n-slate-3 rounded-lg"
>
<Widget
:welcome-heading="welcomeHeading"
:welcome-tagline="welcomeTagline"
:website-name="websiteName"
:logo="avatarUrl"
is-online
:reply-time="replyTime"
:color="color"
:widget-bubble-position="widgetBubblePosition"
:widget-bubble-launcher-title="widgetBubbleLauncherTitle"
:widget-bubble-type="widgetBubbleType"
/>
</div>
<div
v-else
class="mx-5 p-2.5 bg-n-slate-3 rounded-lg dark:bg-n-solid-3"
>
<woot-code :script="widgetScript" />
</div>
</div>
</div>
</div>
</template>
@@ -44,8 +44,5 @@ async function requestAuthorization() {
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
</template>
@@ -30,8 +30,5 @@ async function requestAuthorization() {
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
</template>
@@ -37,8 +37,5 @@ async function requestAuthorization() {
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
</template>
@@ -30,8 +30,5 @@ async function requestAuthorization() {
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
<InboxReconnectionRequired class="mx-6" @reauthorize="requestAuthorization" />
</template>
@@ -201,7 +201,7 @@ defineExpose({
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
class="mx-6"
:is-loading="isRequestingAuthorization"
:action-label="actionLabel"
:description="description"
@@ -136,18 +136,18 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
</script>
<template>
<div class="gap-4 pt-8 mx-8">
<div class="gap-4 mx-6">
<div
class="px-5 py-5 space-y-6 rounded-xl border shadow-sm border-n-weak bg-n-solid-2"
class="px-5 py-5 space-y-6 rounded-xl outline outline-1 -outline-offset-1 outline-n-weak bg-n-solid-2"
>
<div
class="flex flex-col gap-5 justify-between items-start w-full md:flex-row"
>
<div>
<span class="text-base font-medium text-n-slate-12">
<span class="text-heading-3 text-n-slate-12">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.TITLE') }}
</span>
<p class="mt-1 text-sm text-n-slate-11">
<p class="mt-1 text-body-main text-n-slate-11">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.DESCRIPTION') }}
</p>
</div>
@@ -169,7 +169,7 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
class="flex flex-col gap-2 p-4 rounded-lg border border-n-weak bg-n-solid-1"
>
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-n-slate-11">
<span class="text-body-main font-medium text-n-slate-11">
{{ item.label }}
</span>
<Icon
@@ -181,32 +181,32 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
<div class="flex items-center">
<span
v-if="item.type === 'quality'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2"
:class="getQualityRatingTextColor(item.value)"
>
{{ item.value }}
</span>
<span
v-else-if="item.type === 'status'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2"
:class="getStatusTextColor(item.value)"
>
{{ formatStatusDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'mode'"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-xs font-medium rounded-md bg-n-alpha-2"
class="inline-flex items-center px-2 py-0.5 min-h-6 text-label-small rounded-md bg-n-alpha-2"
:class="getModeStatusTextColor(item.value)"
>
{{ formatModeDisplay(item.value) }}
</span>
<span
v-else-if="item.type === 'tier'"
class="text-sm font-medium text-n-slate-12"
class="text-label text-n-slate-12"
>
{{ formatTierDisplay(item.value) }}
</span>
<span v-else class="text-sm font-medium text-n-slate-12">{{
<span v-else class="text-label text-n-slate-12">{{
item.value
}}</span>
</div>
@@ -219,7 +219,9 @@ const getStatusTextColor = status => STATUS_COLORS[status] || 'text-n-slate-12';
>
<div>
<Icon icon="i-lucide-activity" class="mb-2 w-8 h-8" />
<p class="text-sm">{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}</p>
<p class="text-body-main text-n-slate-11">
{{ t('INBOX_MGMT.ACCOUNT_HEALTH.NO_DATA') }}
</p>
</div>
</div>
</div>
@@ -1,15 +1,17 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SelectInput from 'dashboard/components-next/select/Select.vue';
export default {
components: {
LoadingState,
SettingsSection,
SettingsFieldSection,
NextButton,
SelectInput,
},
props: {
inbox: {
@@ -27,8 +29,13 @@ export default {
agentBots: 'agentBots/getBots',
uiFlags: 'agentBots/getUIFlags',
}),
currentInboxId() {
return this.inbox?.id || this.$route.params.inboxId;
},
activeAgentBot() {
return this.$store.getters['agentBots/getActiveAgentBot'](this.inbox.id);
return this.$store.getters['agentBots/getActiveAgentBot'](
this.currentInboxId
);
},
},
watch: {
@@ -37,11 +44,14 @@ export default {
},
},
mounted() {
this.$store.dispatch('agentBots/get');
this.$store.dispatch('agentBots/fetchAgentBotInbox', this.inbox.id);
this.fetchBotData();
},
methods: {
fetchBotData() {
this.$store.dispatch('agentBots/get');
this.$store.dispatch('agentBots/fetchAgentBotInbox', this.currentInboxId);
},
async updateActiveAgentBot() {
try {
await this.$store.dispatch('agentBots/setAgentBotInbox', {
@@ -74,51 +84,42 @@ export default {
</script>
<template>
<div class="mx-8">
<div class="mx-6 max-w-3xl">
<LoadingState v-if="uiFlags.isFetching || uiFlags.isFetchingAgentBot" />
<form
v-else
class="flex flex-wrap mx-0"
@submit.prevent="updateActiveAgentBot"
>
<SettingsSection
:title="$t('AGENT_BOTS.BOT_CONFIGURATION.TITLE')"
:sub-title="$t('AGENT_BOTS.BOT_CONFIGURATION.DESC')"
<form v-else @submit.prevent="updateActiveAgentBot">
<SettingsFieldSection
:label="$t('AGENT_BOTS.BOT_CONFIGURATION.TITLE')"
:help-text="$t('AGENT_BOTS.BOT_CONFIGURATION.DESC')"
class="[&>div]:!items-start"
>
<div>
<label>
<select v-model="selectedAgentBotId">
<option value="" disabled selected>
{{ $t('AGENT_BOTS.BOT_CONFIGURATION.SELECT_PLACEHOLDER') }}
</option>
<option
v-for="agentBot in agentBots"
:key="agentBot.id"
:value="agentBot.id"
<SelectInput
v-model="selectedAgentBotId"
:placeholder="$t('AGENT_BOTS.BOT_CONFIGURATION.SELECT_PLACEHOLDER')"
:options="agentBots.map(bot => ({ value: bot.id, label: bot.name }))"
/>
<template #extra>
<div class="grid grid-cols-1 lg:grid-cols-8 mt-3">
<div class="col-span-1 lg:col-span-2 invisible" />
<div class="col-span-1 lg:col-span-6 flex gap-2 mx-1">
<NextButton
type="submit"
:label="$t('AGENT_BOTS.BOT_CONFIGURATION.SUBMIT')"
:is-loading="uiFlags.isSettingAgentBot"
/>
<NextButton
type="button"
:disabled="!selectedAgentBotId"
:is-loading="uiFlags.isDisconnecting"
faded
ruby
@click="disconnectBot"
>
{{ agentBot.name }}
</option>
</select>
</label>
<div class="button-container space-x-2">
<NextButton
type="submit"
:label="$t('AGENT_BOTS.BOT_CONFIGURATION.SUBMIT')"
:is-loading="uiFlags.isSettingAgentBot"
/>
<NextButton
type="button"
:disabled="!selectedAgentBotId"
:is-loading="uiFlags.isDisconnecting"
faded
ruby
@click="disconnectBot"
>
{{ $t('AGENT_BOTS.BOT_CONFIGURATION.DISCONNECT') }}
</NextButton>
{{ $t('AGENT_BOTS.BOT_CONFIGURATION.DISCONNECT') }}
</NextButton>
</div>
</div>
</div>
</SettingsSection>
</template>
</SettingsFieldSection>
</form>
</div>
</template>
@@ -2,11 +2,26 @@
import parse from 'date-fns/parse';
import differenceInMinutes from 'date-fns/differenceInMinutes';
import { generateTimeSlots } from '../helpers/businessHour';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextSelect from 'dashboard/components-next/select/Select.vue';
const timeSlots = generateTimeSlots(30);
const groupByPeriod = slots =>
['AM', 'PM']
.map(period => ({
label: period,
options: slots
.filter(s => s.endsWith(period))
.map(s => ({ value: s, label: s })),
}))
.filter(g => g.options.length);
export default {
components: {},
components: {
Icon,
NextSelect,
},
props: {
dayName: {
type: String,
@@ -23,12 +38,10 @@ export default {
emits: ['update'],
computed: {
fromTimeSlots() {
return timeSlots;
return groupByPeriod(timeSlots);
},
toTimeSlots() {
return timeSlots.filter(slot => {
return slot !== '12:00 AM';
});
return groupByPeriod(timeSlots.filter(slot => slot !== '12:00 AM'));
},
isDayEnabled: {
get() {
@@ -135,99 +148,67 @@ export default {
</script>
<template>
<div
class="day-wrap flex py-2 gap-1 items-center px-0 min-h-[3rem] box-content border-b border-solid border-n-weak"
>
<div class="checkbox-wrap flex items-center">
<input
v-model="isDayEnabled"
name="enable-day"
class="m-0"
type="checkbox"
:title="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.ENABLE')"
/>
</div>
<div
class="day flex items-center py-0 px-3 text-sm font-medium flex-shrink-0 min-w-28"
>
<span>{{ dayName }}</span>
</div>
<div
v-if="isDayEnabled"
class="flex flex-col flex-shrink-0 flex-grow relative"
>
<div class="flex items-center flex-shrink-0 flex-grow">
<div class="checkbox-wrap flex items-center open-all-day mr-6">
<input
v-model="isOpenAllDay"
name="enable-open-all-day"
class="enable-checkbox text-sm font-medium"
type="checkbox"
:title="$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')"
<tr>
<td class="ltr:pl-4 ltr:pr-3 rtl:pl-3 rtl:pr-4">
<div class="flex items-center gap-2 min-h-16">
<input
v-model="isDayEnabled"
name="enable-day"
class="m-0"
type="checkbox"
:title="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.ENABLE')"
/>
<span class="text-body-main text-n-slate-12 font-medium">
{{ dayName }}
</span>
</div>
</td>
<td class="py-3 ltr:pr-3 rtl:pl-3">
<div v-if="isDayEnabled" class="flex flex-col gap-1.5">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<input
v-model="isOpenAllDay"
name="enable-open-all-day"
class="m-0"
type="checkbox"
:title="$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')"
/>
<span class="text-body-main text-n-slate-12">{{
$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')
}}</span>
</div>
<NextSelect
v-model="fromTime"
:groups="fromTimeSlots"
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
:disabled="isOpenAllDay"
/>
<div class="flex items-center">
<Icon icon="i-lucide-minus size-4" />
</div>
<NextSelect
v-model="toTime"
:groups="toTimeSlots"
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
:disabled="isOpenAllDay"
/>
<span class="text-sm font-medium ml-1">{{
$t('INBOX_MGMT.BUSINESS_HOURS.ALL_DAY')
}}</span>
</div>
<multiselect
v-model="fromTime"
:options="fromTimeSlots"
deselect-label=""
select-label=""
selected-label=""
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
:allow-empty="false"
:disabled="isOpenAllDay"
/>
<div class="separator-icon flex items-center py-0 px-3">
<fluent-icon icon="subtract" type="solid" size="16" />
</div>
<multiselect
v-model="toTime"
:options="toTimeSlots"
deselect-label=""
select-label=""
selected-label=""
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
:allow-empty="false"
:disabled="isOpenAllDay"
/>
<span v-if="hasError" class="error text-label-small text-n-ruby-9">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.VALIDATION_ERROR') }}
</span>
</div>
<div v-if="hasError" class="date-error pt-1">
<span class="error text-xs text-n-ruby-9">{{
$t('INBOX_MGMT.BUSINESS_HOURS.DAY.VALIDATION_ERROR')
}}</span>
</div>
</div>
<div
v-else
class="flex items-center flex-shrink-0 flex-grow text-sm text-n-slate-11"
>
<span>
<span v-else class="text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.UNAVAILABLE') }}
</span>
</div>
<div>
</td>
<td class="py-3 ltr:pr-3 rtl:pl-3">
<span
v-if="isDayEnabled && !hasError"
class="label bg-n-brand/10 dark:bg-n-brand/30 text-n-blue-11 text-xs inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
class="label bg-n-blue-3 text-n-blue-11 text-label-small inline-block px-2 py-1 rounded-lg cursor-default whitespace-nowrap"
>
{{ totalHours }}
</span>
</div>
</div>
</td>
</tr>
</template>
<style lang="scss" scoped>
.day-wrap::v-deep .multiselect {
@apply m-0 w-[7.5rem];
> .multiselect__tags {
@apply pl-3;
.multiselect__single {
@apply text-sm leading-6 py-2 px-0;
}
}
}
</style>
@@ -1,114 +1,98 @@
<script>
import PreviewCard from 'dashboard/components/ui/PreviewCard.vue';
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
export default {
components: {
PreviewCard,
Avatar,
const props = defineProps({
senderNameType: {
type: String,
default: 'friendly',
},
props: {
senderNameType: {
type: String,
default: 'friendly',
},
businessName: {
type: String,
default: '',
businessName: {
type: String,
default: '',
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const senderNameKeyOptions = computed(() => [
{
key: 'friendly',
heading: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.TITLE'),
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'),
preview: {
senderName: 'Smith',
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
emits: ['update'],
data() {
return {
senderNameKeyOptions: [
{
key: 'friendly',
heading: this.$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.TITLE'
),
content: this.$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.SUBTITLE'
),
preview: {
senderName: 'Smith',
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
{
key: 'professional',
heading: this.$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.TITLE'
),
content: this.$t(
'INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'
),
preview: {
senderName: '',
businessName: 'Chatwoot ',
email: '<support@yourbusiness.com>',
},
},
],
};
},
methods: {
isKeyOptionFriendly(key) {
return key === 'friendly';
},
userName(keyOption) {
return this.isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: keyOption.preview.businessName;
},
toggleSenderNameType(key) {
this.$emit('update', key);
{
key: 'professional',
heading: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.TITLE'),
content: t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.PROFESSIONAL.SUBTITLE'),
preview: {
senderName: '',
businessName: 'Chatwoot',
email: '<support@yourbusiness.com>',
},
},
]);
const isKeyOptionFriendly = key => key === 'friendly';
const userName = keyOption =>
isKeyOptionFriendly(keyOption.key)
? keyOption.preview.senderName
: keyOption.preview.businessName;
const toggleSenderNameType = key => {
emit('update', key);
};
</script>
<template>
<div class="flex flex-col lg:flex-row items-start lg:items-center gap-4">
<button
<div
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
>
<RadioCard
v-for="keyOption in senderNameKeyOptions"
:id="keyOption.key"
:key="keyOption.key"
class="text-n-slate-12 cursor-pointer p-0"
@click="toggleSenderNameType(keyOption.key)"
:label="keyOption.heading"
:description="keyOption.content"
:is-active="keyOption.key === props.senderNameType"
class="flex-1 !gap-2"
@select="toggleSenderNameType"
>
<PreviewCard
:heading="keyOption.heading"
:content="keyOption.content"
:active="keyOption.key === senderNameType"
>
<div class="flex flex-col items-start p-3 gap-2">
<span class="text-xs">
{{ $t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FOR_EG') }}
</span>
<div class="flex flex-row items-center gap-2">
<Avatar :name="userName(keyOption)" :size="32" rounded-full />
<div class="flex flex-col items-start gap-1">
<div class="items-center flex flex-row gap-0.5 max-w-[18rem]">
<span
v-if="isKeyOptionFriendly(keyOption.key)"
class="text-xs font-semibold leading-tight"
>
{{ keyOption.preview.senderName }}
</span>
<span v-if="isKeyOptionFriendly(keyOption.key)" class="text-xs">
{{ $t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.FROM') }}
</span>
<span
class="text-xs font-semibold leading-tight overflow-hidden whitespace-nowrap text-ellipsis"
>
{{ businessName || keyOption.preview.businessName }}
</span>
</div>
<span class="text-xs">{{ keyOption.preview.email }}</span>
</div>
<div class="flex items-center gap-3">
<Avatar :name="userName(keyOption)" :size="30" />
<div class="flex flex-col">
<div class="flex items-center gap-1">
<span
v-if="isKeyOptionFriendly(keyOption.key)"
class="text-body-main text-n-slate-12"
>
{{ keyOption.preview.senderName }}
</span>
<span
v-if="isKeyOptionFriendly(keyOption.key)"
class="text-body-main text-n-slate-11"
>
{{ t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.FRIENDLY.FROM') }}
</span>
<span class="text-body-main text-n-slate-12">
{{ props.businessName || keyOption.preview.businessName }}
</span>
</div>
<span class="text-label-small text-n-slate-11">
{{ keyOption.preview.email }}
</span>
</div>
</PreviewCard>
</button>
</div>
</RadioCard>
</div>
</template>
@@ -2,7 +2,8 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import BusinessDay from './BusinessDay.vue';
import {
@@ -12,6 +13,7 @@ import {
timeZoneOptions,
} from '../helpers/businessHour';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const DEFAULT_TIMEZONE = {
label: 'Pacific Time (US & Canada) (GMT-07:00)',
@@ -20,10 +22,12 @@ const DEFAULT_TIMEZONE = {
export default {
components: {
SettingsSection,
SettingsToggleSection,
SettingsFieldSection,
BusinessDay,
NextButton,
WootMessageEditor,
ComboBox,
},
mixins: [inboxMixin],
props: {
@@ -58,6 +62,15 @@ export default {
timeZones() {
return [...timeZoneOptions()];
},
timeZoneValue: {
get() {
return this.timeZone.value;
},
set(value) {
const match = this.timeZones.find(tz => tz.value === value);
if (match) this.timeZone = match;
},
},
isRichEditorEnabled() {
if (
this.isATwilioChannel ||
@@ -121,96 +134,104 @@ export default {
</script>
<template>
<div class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.BUSINESS_HOURS.TITLE')"
:sub-title="$t('INBOX_MGMT.BUSINESS_HOURS.SUBTITLE')"
<div class="mx-6">
<SettingsToggleSection
v-model="isBusinessHoursEnabled"
:header="$t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_AVAILABILITY')"
:description="$t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-business-hours" class="toggle-input-wrap">
<input
v-model="isBusinessHoursEnabled"
type="checkbox"
class="ltr:mr-2 rtl:ml-2"
name="toggle-business-hours"
/>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_AVAILABILITY') }}
</label>
<p class="mb-4 text-n-slate-11">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP') }}
</p>
<div v-if="isBusinessHoursEnabled" class="mb-6">
<div>
<label class="unavailable-input-wrap">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
</label>
<div
v-if="isRichEditorEnabled"
class="px-4 py-0 mx-0 mt-0 mb-4 rounded-lg outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 bg-n-alpha-black2"
>
<WootMessageEditor
v-model="unavailableMessage"
enable-variables
is-format-mode
:min-height="4"
/>
</div>
<textarea v-else v-model="unavailableMessage" type="text" />
</div>
<div class="timezone-input-wrap">
<label>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL') }}
</label>
<multiselect
v-model="timeZone"
:options="timeZones"
deselect-label=""
select-label=""
selected-label=""
track-by="value"
label="label"
close-on-select
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
:allow-empty="false"
/>
</div>
<label>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.WEEKLY_TITLE') }}
</label>
<BusinessDay
v-for="timeSlot in timeSlots"
:key="timeSlot.day"
:day-name="dayNames[timeSlot.day]"
:time-slot="timeSlot"
@update="data => onSlotUpdate(timeSlot.day, data)"
<template v-if="isBusinessHoursEnabled" #editor>
<div class="mb-4">
<WootMessageEditor
v-if="isRichEditorEnabled"
v-model="unavailableMessage"
enable-variables
is-format-mode
:placeholder="
$t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL')
"
:min-height="4"
/>
<textarea v-else v-model="unavailableMessage" type="text" />
</div>
</template>
</SettingsToggleSection>
<div v-if="isBusinessHoursEnabled" class="flex items-center my-8 py-1">
<div class="flex-1 h-px bg-n-weak" />
<span class="text-body-main text-n-slate-11 px-2">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.WEEKLY_TITLE') }}
</span>
<div class="flex-1 h-px bg-n-weak" />
</div>
<SettingsFieldSection
v-if="isBusinessHoursEnabled"
:label="$t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL')"
>
<ComboBox
v-model="timeZoneValue"
:options="timeZones"
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
class="[&>div>button]:!bg-n-alpha-black2"
/>
</SettingsFieldSection>
<form class="flex flex-col" @submit.prevent="updateInbox">
<div v-if="isBusinessHoursEnabled" class="mt-2">
<div class="w-full">
<table
class="min-w-full table-auto outline outline-1 -outline-offset-1 outline-n-weak rounded-xl"
>
<thead>
<tr class="border-b border-n-weak">
<th
class="py-3 ltr:pl-4 ltr:pr-3 rtl:pl-3 rtl:pr-4 text-start text-heading-3 text-n-slate-12"
>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.DAY') }}
</th>
<th
class="py-3 ltr:pr-3 rtl:pl-3 text-start text-heading-3 text-n-slate-12"
>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.AVAILABILITY') }}
</th>
<th
class="py-3 ltr:pr-3 rtl:pl-3 text-start text-heading-3 text-n-slate-12"
>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.DAY.HOURS') }}
</th>
</tr>
</thead>
<tbody class="divide-y divide-n-weak">
<BusinessDay
v-for="timeSlot in timeSlots"
:key="timeSlot.day"
:day-name="dayNames[timeSlot.day]"
:time-slot="timeSlot"
@update="data => onSlotUpdate(timeSlot.day, data)"
/>
</tbody>
</table>
</div>
</div>
<div class="w-full flex justify-end items-center py-4 mt-2">
<NextButton
type="submit"
:label="$t('INBOX_MGMT.BUSINESS_HOURS.UPDATE')"
:is-loading="uiFlags.isUpdating"
:disabled="hasError"
/>
</form>
</SettingsSection>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.timezone-input-wrap {
&::v-deep .multiselect {
@apply mt-2;
}
}
::v-deep.message-editor {
@apply border-0;
}
.unavailable-input-wrap {
textarea {
@apply min-h-[4rem] mt-2;
}
textarea {
@apply min-h-[4rem] mt-1.5;
}
</style>
@@ -99,7 +99,7 @@ export default {
</script>
<template>
<InboxReconnectionRequired class="mx-8 mt-5" @reauthorize="startLogin" />
<InboxReconnectionRequired class="mx-6" @reauthorize="startLogin" />
</template>
<style lang="scss" scoped>
@@ -3,7 +3,7 @@ import { frontendURL } from '../../../../helper/URLHelper';
import ChannelFactory from './ChannelFactory.vue';
import SettingsContent from '../Wrapper.vue';
import SettingWrapper from '../SettingsWrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
import InboxHome from './Index.vue';
import Settings from './Settings.vue';
import InboxChannel from './InboxChannels.vue';
@@ -15,7 +15,7 @@ export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/inboxes'),
component: SettingWrapper,
component: SettingsWrapper,
children: [
{
path: '',
@@ -7,10 +7,13 @@ import { useVuelidate } from '@vuelidate/core';
import { minValue } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useConfig } from 'dashboard/composables/useConfig';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsAccordion from 'dashboard/components-next/Settings/SettingsAccordion.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import assignmentPoliciesAPI from 'dashboard/api/assignmentPolicies';
import { useI18n } from 'vue-i18n';
@@ -27,7 +30,7 @@ const router = useRouter();
const { t } = useI18n();
const { isEnterprise } = useConfig();
const selectedAgents = ref([]);
const selectedAgentIds = ref([]);
const isAgentListUpdating = ref(false);
const enableAutoAssignment = ref(false);
const maxAssignmentLimit = ref(null);
@@ -42,6 +45,33 @@ const isLinkingPolicy = ref(false);
const agentList = computed(() => store.getters['agents/getAgents']);
const selectedAgentNames = computed(() =>
selectedAgentIds.value.map(
id => agentList.value.find(a => a.id === id)?.name ?? ''
)
);
const agentMenuItems = computed(() =>
agentList.value
.filter(({ id }) => !selectedAgentIds.value.includes(id))
.map(({ id, name, thumbnail, avatar_url }) => ({
label: name,
value: id,
action: 'select',
thumbnail: { name, src: thumbnail || avatar_url || '' },
}))
);
const handleAgentAdd = ({ value }) => {
if (!selectedAgentIds.value.includes(value)) {
selectedAgentIds.value.push(value);
}
};
const handleAgentRemove = index => {
selectedAgentIds.value.splice(index, 1);
};
const isFeatureEnabled = feature => {
const accountId = Number(route.params.accountId);
return store.getters['accounts/isFeatureEnabledonAccount'](
@@ -95,6 +125,18 @@ const rules = {
const v$ = useVuelidate(rules, { maxAssignmentLimit });
const assignmentHeader = computed(() =>
hasAssignmentV2.value
? t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT')
: t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT')
);
const assignmentDescription = computed(() =>
hasAssignmentV2.value
? t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION')
: t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT')
);
const maxAssignmentLimitErrors = computed(() => {
if (v$.value.maxAssignmentLimit.$error) {
return t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_RANGE_ERROR');
@@ -110,7 +152,7 @@ const fetchAttachedAgents = async () => {
const {
data: { payload: inboxMembers },
} = response;
selectedAgents.value = inboxMembers;
selectedAgentIds.value = inboxMembers.map(m => m.id);
} catch (error) {
// Handle error
}
@@ -204,12 +246,13 @@ const closePolicyDropdown = () => {
showPolicyDropdown.value = false;
};
const handleToggleAutoAssignment = async () => {
const handleToggleAutoAssignment = async val => {
enableAutoAssignment.value = val;
try {
const payload = {
id: props.inbox.id,
formData: false,
enable_auto_assignment: enableAutoAssignment.value,
enable_auto_assignment: val,
};
await store.dispatch('inboxes/updateInbox', payload);
useAlert(t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
@@ -219,12 +262,11 @@ const handleToggleAutoAssignment = async () => {
};
const updateAgents = async () => {
const agentListIds = selectedAgents.value.map(el => el.id);
isAgentListUpdating.value = true;
try {
await store.dispatch('inboxMembers/create', {
inboxId: props.inbox.id,
agentList: agentListIds,
agentList: selectedAgentIds.value,
});
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
@@ -320,81 +362,76 @@ onMounted(() => {
<template>
<div>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS_SUB_TEXT')"
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_AGENTS_SUB_TEXT')"
class="[&>div]:!items-start"
>
<multiselect
v-model="selectedAgents"
:options="agentList"
track-by="id"
label="name"
multiple
:close-on-select="false"
:clear-on-select="false"
hide-selected
placeholder="Pick some"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
/>
<div
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
>
<TagInput
:model-value="selectedAgentNames"
:placeholder="$t('INBOX_MGMT.ADD.AGENTS.PICK_AGENTS')"
:menu-items="agentMenuItems"
show-dropdown
skip-label-dedup
:auto-open-dropdown="false"
@add="handleAgentAdd"
@remove="handleAgentRemove"
/>
</div>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isAgentListUpdating"
@click="updateAgents"
/>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT_SUB_TEXT')"
>
<!-- New UI for assignment_v2 -->
<template v-if="hasAssignmentV2">
<div class="flex items-start gap-3">
<Switch
v-model="enableAutoAssignment"
class="flex-shrink-0 mt-0.5"
@change="handleToggleAutoAssignment"
/>
<div class="flex-grow">
<label class="text-sm text-n-slate-12 font-medium mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.ENABLE_AUTO_ASSIGNMENT') }}
</label>
<p class="text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DESCRIPTION') }}
</p>
<template #extra>
<div class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2" />
<div class="col-span-1 lg:col-span-6 mt-4 justify-self-end">
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isAgentListUpdating"
@click="updateAgents"
/>
</div>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
</template>
</SettingsFieldSection>
<SettingsAccordion
:title="$t('INBOX_MGMT.SETTINGS_POPUP.AGENT_ASSIGNMENT')"
class="mt-6"
>
<SettingsToggleSection
v-model="enableAutoAssignment"
compact
:header="assignmentHeader"
:description="assignmentDescription"
@update:model-value="handleToggleAutoAssignment"
>
<template
v-if="enableAutoAssignment && (isEnterprise || hasAssignmentV2)"
#editor
>
<div v-if="enableAutoAssignment" class="mt-6">
<!-- assignment_v2 UI -->
<template v-if="hasAssignmentV2">
<!-- Policy Card - When policy is attached -->
<div
v-if="showAdvancedAssignmentUI && assignmentPolicy"
class="p-4 rounded-xl outline-1 outline-n-weak outline bg-n-solid-1 dark:bg-n-slate-1"
class="ltr:pr-0 rtl:pl-0 ltr:pl-4 rtl:pr-4 py-4"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 size-12 rounded-xl bg-n-slate-3 flex items-center justify-center"
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<div class="flex items-start justify-between gap-4 mb-4">
<div
class="flex items-start justify-between gap-4 mb-4 ltr:pr-4 rtl:pl-4"
>
<div class="flex flex-col items-start">
<span class="text-base font-medium text-n-slate-12 mb-1">
<span class="text-heading-3 text-n-slate-12 mb-1">
{{ assignmentPolicy.name }}
</span>
<p class="text-sm text-n-slate-11">
<p class="text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.POLICY_LABEL') }}
</p>
</div>
@@ -412,7 +449,7 @@ onMounted(() => {
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
<span class="text-body-main text-n-slate-12">
{{ assignmentOrderLabel }}
</span>
</li>
@@ -420,7 +457,7 @@ onMounted(() => {
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm text-n-slate-12">
<span class="text-body-main text-n-slate-12">
{{ assignmentMethodLabel }}
</span>
</li>
@@ -447,21 +484,20 @@ onMounted(() => {
!assignmentPolicy &&
!isLoadingPolicy
"
class="rounded-xl outline-1 outline-n-weak outline"
>
<!-- Default Policy Header -->
<div class="p-4">
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
<h4 class="text-heading-3 text-n-slate-12 mb-0.5">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_LINKED') }}
</h4>
<p class="text-sm text-n-slate-11">
<p class="text-body-main text-n-slate-11">
{{
$t('INBOX_MGMT.ASSIGNMENT.DEFAULT_POLICY_DESCRIPTION')
}}
@@ -476,45 +512,47 @@ onMounted(() => {
v-on-click-outside="closePolicyDropdown"
class="relative"
>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-n-brand hover:bg-n-brand/90 rounded-lg transition-colors"
<NextButton
icon="i-lucide-link"
sm
@click="togglePolicyDropdown"
>
<i class="i-lucide-link text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.LINK_EXISTING_POLICY') }}
<i
class="i-lucide-chevron-down text-sm transition-transform"
<Icon
icon="i-lucide-chevron-down"
class="transition-transform flex-shrink-0"
:class="{ 'rotate-180': showPolicyDropdown }"
/>
</button>
</NextButton>
<DropdownMenu
v-if="showPolicyDropdown"
class="top-full left-0 mt-2 min-w-72"
class="top-full ltr:left-0 rtl:right-0 mt-2 max-w-64 max-h-72 overflow-y-auto"
:menu-items="policyMenuItems"
:is-searching="isLoadingPolicies"
@action="handlePolicyMenuAction"
/>
</div>
<button
type="button"
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-n-slate-12 bg-n-slate-3 dark:bg-n-slate-4 hover:bg-n-slate-4 dark:hover:bg-n-slate-5 rounded-lg transition-colors"
<NextButton
icon="i-lucide-plus"
:label="$t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY')"
slate
faded
sm
@click="navigateToCreatePolicy"
>
<i class="i-lucide-plus text-sm" />
{{ $t('INBOX_MGMT.ASSIGNMENT.CREATE_NEW_POLICY') }}
</button>
/>
</div>
</div>
<!-- Default Rules Info -->
<div class="px-4 py-4 border-t border-n-weak bg-n-slate-2">
<div
class="px-4 py-4 border-t border-n-weak bg-n-slate-2 rounded-b-xl"
>
<div class="flex items-start gap-3">
<i class="i-lucide-info text-base text-n-slate-10 mt-0.5" />
<Icon icon="i-lucide-info" class="mt-0.5 text-n-slate-11" />
<div>
<p class="text-sm text-n-slate-11 mb-2">
<p class="text-body-main text-n-slate-11 mb-2">
{{ $t('INBOX_MGMT.ASSIGNMENT.CURRENT_BEHAVIOR') }}
</p>
<ul class="space-y-1">
@@ -522,7 +560,7 @@ onMounted(() => {
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
<span class="text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
@@ -530,7 +568,7 @@ onMounted(() => {
<span
class="w-1 h-1 rounded-full bg-n-slate-10 flex-shrink-0"
/>
<span class="text-sm text-n-slate-11">
<span class="text-body-main text-n-slate-11">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
@@ -543,19 +581,19 @@ onMounted(() => {
<!-- Default Rules Card - Feature not enabled (no advanced_assignment) -->
<div
v-else-if="!showAdvancedAssignmentUI"
class="p-4 rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
class="ltr:pr-0 rtl:pl-0 ltr:pl-4 rtl:pr-4 py-4"
>
<div class="flex items-start gap-4">
<div
class="flex-shrink-0 w-12 h-12 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
class="flex-shrink-0 size-10 rounded-xl bg-n-slate-3 dark:bg-n-slate-4 flex items-center justify-center"
>
<i class="i-lucide-zap text-xl text-n-slate-11" />
<Icon icon="i-lucide-zap" class="text-xl text-n-slate-11" />
</div>
<div class="flex-grow">
<h4 class="text-base font-medium text-n-slate-12 mb-1">
<h4 class="text-heading-3 text-n-slate-12 mb-0.5">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_TITLE') }}
</h4>
<p class="text-sm text-n-slate-11 mb-4">
<p class="text-body-main text-n-slate-11 mb-4">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULES_DESCRIPTION') }}
</p>
@@ -564,7 +602,7 @@ onMounted(() => {
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
<span class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_1') }}
</span>
</li>
@@ -572,7 +610,7 @@ onMounted(() => {
<span
class="w-1.5 h-1.5 rounded-full bg-n-slate-11 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12">
<span class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.ASSIGNMENT.DEFAULT_RULE_2') }}
</span>
</li>
@@ -582,7 +620,7 @@ onMounted(() => {
<!-- Upgrade prompt when advanced_assignment is not enabled -->
<div v-if="!hasAdvancedAssignment">
<p class="text-sm text-n-slate-11 mb-1">
<p class="text-body-main text-n-slate-11 mb-1">
{{ $t('INBOX_MGMT.ASSIGNMENT.UPGRADE_PROMPT') }}
</p>
<NextButton
@@ -596,52 +634,39 @@ onMounted(() => {
</div>
</div>
</div>
</div>
</Transition>
</template>
</template>
<!-- Old UI for non-assignment_v2 -->
<template v-else>
<label class="w-3/4 settings-item">
<div class="flex items-center gap-2">
<input
id="enableAutoAssignment"
v-model="enableAutoAssignment"
type="checkbox"
@change="handleToggleAutoAssignment"
/>
<label for="enableAutoAssignment">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
</label>
</div>
<!-- Old UI for non-assignment_v2 -->
<template v-else-if="isEnterprise">
<div class="p-4">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
class="[&>input]:!mb-0"
@blur="v$.maxAssignmentLimit.$touch"
/>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT_SUB_TEXT') }}
</p>
</label>
<p class="mt-1.5 text-label-small text-n-slate-11">
{{
$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT')
}}
</p>
<div v-if="enableAutoAssignment && isEnterprise" class="py-3">
<woot-input
v-model="maxAssignmentLimit"
type="number"
:class="{ error: v$.maxAssignmentLimit.$error }"
:error="maxAssignmentLimitErrors"
:label="$t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT')"
@blur="v$.maxAssignmentLimit.$touch"
/>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.AUTO_ASSIGNMENT.MAX_ASSIGNMENT_LIMIT_SUB_TEXT') }}
</p>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
</template>
</SettingsSection>
<div class="flex justify-end mt-4">
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:disabled="v$.maxAssignmentLimit.$invalid"
@click="updateInbox"
/>
</div>
</div>
</template>
</template>
</SettingsToggleSection>
</SettingsAccordion>
<woot-modal
v-if="showDeleteConfirmModal"
@@ -1,7 +1,7 @@
<script>
import { useAlert } from 'dashboard/composables';
import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
@@ -13,7 +13,7 @@ import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
components: {
SettingsSection,
SettingsFieldSection,
ImapSettings,
SmtpSettings,
NextButton,
@@ -149,110 +149,108 @@ export default {
</script>
<template>
<div v-if="isATwilioChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
<div v-if="isATwilioChannel" class="mx-6">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
:help-text="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
>
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
</SettingsFieldSection>
<SettingsFieldSection
v-if="isATwilioWhatsAppChannel"
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:sub-title="
<div v-else-if="isAVoiceChannel" class="mx-6">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:sub-title="
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
:help-text="
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
"
>
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
</SettingsSection>
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
<div v-else-if="isALineChannel" class="mx-6">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
:help-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
>
<woot-code :script="inbox.callback_webhook_url" lang="html" />
</SettingsSection>
</SettingsFieldSection>
</div>
<div v-else-if="isAWebWidgetInbox">
<div class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_HEADING')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_SUB_HEAD')"
<div class="mx-6">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
class="[&>div]:!items-start"
>
<woot-code
:script="inbox.web_widget_script"
lang="html"
:codepen-title="`${inbox.name} - Chatwoot Widget Test`"
enable-code-pen
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
min-height="8rem"
class="w-full"
/>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
>
<div class="flex flex-col w-full max-w-3xl gap-4">
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
min-height="8rem"
class="w-full"
/>
<div>
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
<template #extra>
<div class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<div class="col-span-1 lg:col-span-6 mt-4 justify-self-end">
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
</div>
</div>
</div>
</SettingsSection>
</template>
</SettingsFieldSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
>
<woot-code :script="inbox.hmac_token" />
<template #subTitle>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION') }}
<a
target="_blank"
rel="noopener noreferrer"
href="https://www.chatwoot.com/docs/product/channels/live-chat/sdk/identity-validation/"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_LINK_TO_DOCS') }}
</a>
<template #extra>
<div class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
class="col-span-1 lg:col-span-6 mt-1.5 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION') }}
<a
target="_blank"
rel="noopener noreferrer"
href="https://www.chatwoot.com/docs/product/channels/live-chat/sdk/identity-validation/"
class="text-n-blue-11 hover:underline text-label-small"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_LINK_TO_DOCS') }}
</a>
</p>
</div>
</template>
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
>
<div class="flex gap-2 items-center">
<input
@@ -261,30 +259,30 @@ export default {
type="checkbox"
@change="handleHmacFlag"
/>
<label for="hmacMandatory">
<label for="hmacMandatory" class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.EDIT.ENABLE_HMAC.LABEL') }}
</label>
</div>
</SettingsSection>
</SettingsFieldSection>
</div>
</div>
<div v-else-if="isAPIInbox" class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
<div v-else-if="isAPIInbox" class="mx-6">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
>
<woot-code :script="inbox.inbox_identifier" />
</SettingsSection>
</SettingsFieldSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION')"
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION')"
>
<woot-code :script="inbox.hmac_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:sub-title="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
>
<div class="flex gap-2 items-center">
<input
@@ -293,17 +291,17 @@ export default {
type="checkbox"
@change="handleHmacFlag"
/>
<label for="hmacMandatory">
<label for="hmacMandatory" class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.EDIT.ENABLE_HMAC.LABEL') }}
</label>
</div>
</SettingsSection>
</SettingsFieldSection>
</div>
<div v-else-if="isAnEmailChannel">
<div class="mx-8">
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
:sub-title="
<div v-else-if="isAnEmailChannel" class="mx-6">
<div>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
:help-text="
isForwardingEnabled
? $t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_SUB_TEXT')
: ''
@@ -315,71 +313,62 @@ export default {
/>
<div
v-else
class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg"
class="py-2 px-3 bg-n-amber-3 outline-n-amber-4 text-n-amber-11 outline outline-1 -outline-offset-1 rounded-xl"
>
<p class="text-sm text-yellow-800 dark:text-yellow-200 mb-0">
<p class="text-body-para mb-0">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_NOT_CONFIGURED') }}
</p>
</div>
</SettingsSection>
</SettingsFieldSection>
</div>
<ImapSettings :inbox="inbox" />
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
<div v-if="inbox.provider_config" class="mx-6">
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsSection
<SettingsFieldSection
v-if="whatsappAppId"
:title="
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_TITLE')
"
:sub-title="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')
"
:help-text="`${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_SUBHEADER')} ${$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION')}`"
>
<div class="flex gap-4 items-center">
<p class="text-sm text-n-slate-11">
{{
$t(
'INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_EMBEDDED_SIGNUP_DESCRIPTION'
)
}}
</p>
<div class="flex flex-col gap-1 items-start">
<NextButton @click="handleReconfigure">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_RECONFIGURE_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
</SettingsFieldSection>
</template>
<!-- Manual Setup Section -->
<template v-else>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:sub-title="
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_WEBHOOK_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.webhook_verify_token" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:sub-title="
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_SUBHEADER')
"
>
<woot-code :script="inbox.provider_config.api_key" />
</SettingsSection>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:sub-title="
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_SECTION_UPDATE_SUBHEADER')
"
>
<div
class="flex flex-1 justify-between items-center mt-2 whatsapp-settings--content"
class="flex flex-1 justify-between items-center whatsapp-settings--content"
>
<woot-input
v-model="whatsAppInboxAPIKey"
@@ -400,20 +389,18 @@ export default {
}}
</NextButton>
</div>
</SettingsSection>
</SettingsFieldSection>
</template>
<SettingsSection
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:sub-title="
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
:help-text="
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
"
>
<div class="flex justify-start items-center mt-2">
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</div>
</SettingsSection>
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
</NextButton>
</SettingsFieldSection>
</div>
<WhatsappReauthorize
v-if="isEmbeddedSignupWhatsApp"
@@ -8,13 +8,12 @@ import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Switch from 'next/switch/Switch.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
@@ -414,181 +413,179 @@ const handleConfirmTemplateUpdate = async () => {
</script>
<template>
<div class="mx-8">
<SectionLayout
:title="$t('INBOX_MGMT.CSAT.TITLE')"
<div class="mx-6">
<SettingsToggleSection
v-model="state.csatSurveyEnabled"
:header="$t('INBOX_MGMT.CSAT.TITLE')"
:description="$t('INBOX_MGMT.CSAT.SUBTITLE')"
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="state.csatSurveyEnabled" />
</div>
</template>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
v-if="!isAnyWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
<CSATDisplayTypeSelector
:selected-type="state.displayType"
@update="updateDisplayType"
/>
</WithLabel>
<template v-if="isAnyWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
<div class="flex flex-col gap-3 basis-3/5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<template v-if="state.csatSurveyEnabled" #editor>
<div class="grid gap-5">
<!-- Show display type only for non-WhatsApp channels -->
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
v-if="!isAnyWhatsAppChannel"
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
<CSATDisplayTypeSelector
:selected-type="state.displayType"
@update="updateDisplayType"
/>
</WithLabel>
</template>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
name="survey_rule"
>
<div class="mb-4">
<span
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
<template v-if="isAnyWhatsAppChannel">
<div
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
v-model="state.surveyRuleOperator"
variant="faded"
:options="filterTypes"
class="inline-flex shrink-0"
@update:model-value="updateSurveyRuleOperator"
/>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_SUFFIX') }}
<div class="flex flex-col gap-3 basis-3/5">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
channel-type="Context::Plain"
class="w-full"
/>
</WithLabel>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
:placeholder="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER')"
class="w-full"
/>
<NextButton
v-for="label in selectedLabelValues"
:key="label"
sm
faded
slate
trailing-icon
:label="label"
icon="i-lucide-x"
class="inline-flex shrink-0"
@click="removeLabel(label)"
<WithLabel
:label="$t('INBOX_MGMT.CSAT.LANGUAGE.LABEL')"
name="language"
>
<ComboBox
v-model="state.templateLanguage"
:options="languageOptions"
:placeholder="$t('INBOX_MGMT.CSAT.LANGUAGE.PLACEHOLDER')"
/>
</WithLabel>
<div
v-if="shouldShowTemplateStatus"
class="flex gap-2 items-center mt-4"
>
<Icon
:icon="templateApprovalStatus.icon"
:class="templateApprovalStatus.color"
class="size-4"
/>
<span
:class="templateApprovalStatus.color"
class="text-sm font-medium"
>
{{ templateApprovalStatus.text }}
</span>
</div>
</div>
<div
class="flex flex-col flex-shrink-0 justify-start items-center p-6 mt-1 rounded-xl basis-2/5 bg-n-slate-2 outline outline-1 outline-n-weak"
>
<p
class="inline-flex items-center text-sm font-medium text-n-slate-11"
>
{{ $t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.LABEL') }}
<Icon
v-tooltip.top-end="
$t('INBOX_MGMT.CSAT.MESSAGE_PREVIEW.TOOLTIP')
"
icon="i-lucide-info"
class="flex-shrink-0 mx-1 size-4"
/>
</p>
<CSATTemplate
:message="messagePreviewData"
:button-text="state.templateButtonText"
class="pt-12"
/>
</div>
</div>
</template>
<!-- Non-WhatsApp channels layout -->
<template v-else>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.MESSAGE.LABEL')"
name="message"
>
<Editor
v-model="state.message"
:placeholder="$t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER')"
:max-length="200"
class="w-full"
/>
<FilterSelect
v-model="currentLabel"
:options="labelOptions"
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.SELECT_PLACEHOLDER')"
hide-label
variant="faded"
class="inline-flex shrink-0"
@update:model-value="handleLabelSelect"
/>
</span>
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{
isAnyWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
</p>
<div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdating"
@click="saveSettings"
/>
</WithLabel>
</template>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
name="survey_rule"
>
<div class="mb-4">
<span
class="inline-flex flex-wrap gap-1.5 items-center text-sm text-n-slate-12"
>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_PREFIX') }}
<FilterSelect
v-model="state.surveyRuleOperator"
variant="faded"
:options="filterTypes"
class="inline-flex shrink-0"
@update:model-value="updateSurveyRuleOperator"
/>
{{ $t('INBOX_MGMT.CSAT.SURVEY_RULE.DESCRIPTION_SUFFIX') }}
<NextButton
v-for="label in selectedLabelValues"
:key="label"
sm
faded
slate
trailing-icon
:label="label"
icon="i-lucide-x"
class="inline-flex shrink-0"
@click="removeLabel(label)"
/>
<FilterSelect
v-model="currentLabel"
:options="labelOptions"
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.SELECT_PLACEHOLDER')"
hide-label
variant="faded"
class="inline-flex shrink-0"
@update:model-value="handleLabelSelect"
/>
</span>
</div>
</WithLabel>
<p class="text-sm italic text-n-slate-11">
{{
isAnyWhatsAppChannel
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
: $t('INBOX_MGMT.CSAT.NOTE')
}}
</p>
</div>
</div>
</SectionLayout>
</template>
</SettingsToggleSection>
<div class="w-full flex justify-end items-center py-4 mt-2">
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdating"
@click="saveSettings"
/>
</div>
<!-- Template Update Confirmation Dialog -->
<ConfirmTemplateUpdateDialog
@@ -1,4 +1,5 @@
<script setup>
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
@@ -12,40 +13,48 @@ defineEmits(['edit', 'delete']);
</script>
<template>
<tr class="max-w-full py-1">
<td
class="py-4 ltr:pr-4 rtl:pl-4 text-sm w-40 max-w-[10rem] truncate"
:title="app.title"
>
{{ app.title }}
</td>
<td
class="max-w-lg py-4 ltr:pr-4 rtl:pl-4 text-sm truncate"
:title="app.content[0].url"
>
{{ app.content[0].url }}
</td>
<td class="flex gap-1 py-4 ltr:pr-4 rtl:pl-4 text-sm sm:pr-0 justify-end">
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.EDIT_TOOLTIP')
"
icon="i-lucide-pen"
slate
xs
faded
@click="$emit('edit', app)"
/>
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.DELETE_TOOLTIP')
"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', app)"
/>
</td>
</tr>
<BaseTableRow :item="app">
<template #default>
<BaseTableCell>
<span
class="text-body-main text-n-slate-12 truncate block"
:title="app.title"
>
{{ app.title }}
</span>
</BaseTableCell>
<BaseTableCell>
<span
class="text-body-main text-n-slate-11 truncate block"
:title="app.content[0].url"
>
{{ app.content[0].url }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex justify-end gap-3 flex-shrink-0">
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.EDIT_TOOLTIP')
"
icon="i-woot-edit-pen"
slate
sm
@click="$emit('edit', app)"
/>
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.DELETE_TOOLTIP')
"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', app)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -1,14 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import { BaseTable } from 'dashboard/components-next/table';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import SettingsLayout from '../../SettingsLayout.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
SettingsLayout,
BaseTable,
DashboardAppModal,
DashboardAppsRow,
NextButton,
@@ -20,6 +25,7 @@ export default {
showDeleteConfirmationPopup: false,
selectedApp: {},
mode: 'CREATE',
searchQuery: '',
};
},
computed: {
@@ -27,12 +33,20 @@ export default {
records: 'dashboardApps/getRecords',
uiFlags: 'dashboardApps/getUIFlags',
}),
filteredRecords() {
const query = this.searchQuery.trim();
if (!query) return this.records;
return picoSearch(this.records, query, ['title']);
},
tableHeaders() {
return [
this.$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER.NAME'),
this.$t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER.ENDPOINT'
),
this.$t(
'INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.TABLE_HEADER.ACTIONS'
),
];
},
},
@@ -84,59 +98,62 @@ export default {
</script>
<template>
<div class="flex flex-col flex-1 gap-8 overflow-auto">
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.TITLE')"
:description="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DESCRIPTION')"
:link-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LEARN_MORE')"
feature-name="dashboard_apps"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template #actions>
<NextButton
icon="i-lucide-circle-plus"
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT')"
@click="openCreatePopup"
/>
</template>
</BaseSettingsHeader>
<div class="w-full overflow-x-auto text-n-slate-11">
<p
v-if="!uiFlags.isFetching && !records.length"
class="flex flex-col items-center justify-center h-full"
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.LOADING')"
:no-records-found="!records.length"
:no-records-message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.404')"
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.TITLE')"
:description="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DESCRIPTION')"
:link-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.SEARCH_PLACEHOLDER')
"
feature-name="dashboard_apps"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.404') }}
</p>
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.LIST.LOADING')"
/>
<table
v-if="!uiFlags.isFetching && records.length"
class="min-w-full divide-y divide-n-weak"
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.COUNT', {
n: records.length,
})
}}
</span>
</template>
<template #actions>
<NextButton
:label="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.HEADER_BTN_TXT')"
size="sm"
@click="openCreatePopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<span
v-if="!filteredRecords.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 font-semibold text-left text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak">
{{ $t('INTEGRATION_SETTINGS.DASHBOARD_APPS.NO_RESULTS') }}
</span>
<BaseTable v-else :headers="tableHeaders" :items="filteredRecords">
<template #row="{ items }">
<DashboardAppsRow
v-for="(dashboardAppItem, index) in records"
v-for="(dashboardAppItem, index) in items"
:key="dashboardAppItem.id"
:index="index"
:app="dashboardAppItem"
@edit="editApp"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
</template>
</BaseTable>
</template>
<DashboardAppModal
v-if="showDashboardAppPopup"
:show="showDashboardAppPopup"
@@ -160,5 +177,5 @@ export default {
"
:reject-text="$t('INTEGRATION_SETTINGS.DASHBOARD_APPS.DELETE.CONFIRM_NO')"
/>
</div>
</SettingsLayout>
</template>
@@ -1,7 +1,8 @@
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import IntegrationItem from './IntegrationItem.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -10,12 +11,19 @@ const store = useStore();
const getters = useStoreGetters();
const { replaceInstallationName } = useBranding();
const searchQuery = ref('');
const uiFlags = getters['integrations/getUIFlags'];
const integrationList = computed(
() => getters['integrations/getAppIntegrations'].value
);
const filteredIntegrationList = computed(() => {
const query = searchQuery.value.trim();
if (!query) return integrationList.value;
return picoSearch(integrationList.value, query, ['name', 'description']);
});
onMounted(() => {
store.dispatch('integrations/get');
});
@@ -28,19 +36,30 @@ onMounted(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('INTEGRATION_SETTINGS.HEADER')"
:description="
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
"
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
:search-placeholder="$t('INTEGRATION_SETTINGS.SEARCH_PLACEHOLDER')"
feature-name="integrations"
/>
</template>
<template #body>
<div class="flex-grow flex-shrink overflow-auto">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<span
v-if="!filteredIntegrationList.length && searchQuery"
class="flex-1 flex items-center justify-center py-20 text-center text-body-main !text-base text-n-slate-11"
>
{{ $t('INTEGRATION_SETTINGS.NO_RESULTS') }}
</span>
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
>
<IntegrationItem
v-for="item in integrationList"
v-for="item in filteredIntegrationList"
:id="item.id"
:key="item.id"
:logo="item.logo"
@@ -62,7 +62,7 @@ const confirmDeletion = () => {
<template>
<div
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow gap-6"
class="flex flex-col items-start justify-between lg:flex-row lg:items-center p-6 outline outline-n-container outline-1 bg-n-card rounded-xl gap-6"
>
<div
class="flex items-start lg:items-center justify-start flex-1 m-0 gap-6 flex-col lg:flex-row"
@@ -78,10 +78,10 @@ const confirmDeletion = () => {
/>
</div>
<div>
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
<h3 class="mb-1 text-heading-1 text-n-slate-12">
{{ integrationName }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
<p class="text-n-slate-11 text-body-main">
{{ replaceInstallationName(integrationDescription) }}
</p>
</div>
@@ -6,12 +6,16 @@ import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import NewHook from './NewHook.vue';
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
export default {
components: {
NewHook,
SingleIntegrationHooks,
MultipleIntegrationHooks,
SettingsLayout,
BaseSettingsHeader,
},
props: {
integrationId: {
@@ -28,6 +32,7 @@ export default {
isIntegrationSingle,
isHookTypeInbox,
} = useIntegrationHook(integrationId);
return {
integration,
isIntegrationMultiple,
@@ -71,6 +76,9 @@ export default {
return this.$t('INTEGRATION_APPS.DELETE.CANCEL_BUTTON_TEXT');
},
},
mounted() {
this.$store.dispatch('integrations/get');
},
methods: {
openAddHookModal() {
this.showAddHookModal = true;
@@ -108,26 +116,35 @@ export default {
</script>
<template>
<div class="overflow-auto p-4 w-full my-auto flex flex-wrap h-full">
<div v-if="showIntegrationHooks" class="w-full">
<div v-if="isIntegrationMultiple">
<MultipleIntegrationHooks
:integration-id="integrationId"
:show-add-button="showAddButton"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
<SettingsLayout :is-loading="uiFlags.isFetching">
<template v-if="isIntegrationSingle" #header>
<BaseSettingsHeader
:title="integration.name || ''"
description=""
:feature-name="integrationId"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<div v-if="showIntegrationHooks" class="w-full">
<div v-if="isIntegrationMultiple">
<MultipleIntegrationHooks
:integration-id="integrationId"
:show-add-button="showAddButton"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
<div v-if="isIntegrationSingle">
<SingleIntegrationHooks
:integration-id="integrationId"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
<div v-if="isIntegrationSingle">
<SingleIntegrationHooks
:integration-id="integrationId"
@add="openAddHookModal"
@delete="openDeletePopup"
/>
</div>
</div>
</div>
</template>
<woot-modal v-model:show="showAddHookModal" :on-close="hideAddHookModal">
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
</woot-modal>
@@ -141,5 +158,5 @@ export default {
:confirm-text="confirmText"
:reject-text="cancelText"
/>
</div>
</SettingsLayout>
</template>
@@ -6,6 +6,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper';
import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
id: {
@@ -39,7 +40,7 @@ const integrationStatus = computed(() =>
);
const integrationStatusColor = computed(() =>
props.enabled ? 'bg-n-teal-9' : 'bg-n-slate-8'
props.enabled ? 'teal' : 'slate'
);
const actionURL = computed(() =>
@@ -49,10 +50,10 @@ const actionURL = computed(() =>
<template>
<div
class="flex flex-col flex-1 p-6 m-[1px] outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
class="flex flex-col flex-1 p-4 m-px outline outline-n-container outline-1 bg-n-card rounded-xl"
>
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 mb-4">
<div class="flex h-12 w-12 mb-2">
<img
:src="`/dashboard/images/integrations/${id}.png`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
@@ -62,24 +63,27 @@ const actionURL = computed(() =>
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>
<span
v-tooltip="integrationStatus"
class="text-white p-0.5 rounded-full w-5 h-5 flex items-center justify-center"
:class="integrationStatusColor"
>
<i class="i-ph-check-bold text-sm" />
</span>
<Label
:label="integrationStatus"
:color="integrationStatusColor"
compact
/>
</div>
<div class="flex flex-col m-0 flex-1">
<div
class="font-medium mb-2 text-n-slate-12 flex justify-between items-center"
>
<span class="text-base font-semibold">{{ name }}</span>
<span class="text-heading-3 text-n-slate-12">{{ name }}</span>
<router-link :to="actionURL">
<Button :label="$t('INTEGRATION_APPS.CONFIGURE')" link />
<Button
:label="$t('INTEGRATION_APPS.CONFIGURE')"
icon="i-woot-settings"
link
xs
/>
</router-link>
</div>
<p class="text-n-slate-11">
<p class="text-n-slate-11 text-body-main">
{{ replaceInstallationName(description) }}
</p>
</div>
@@ -7,7 +7,8 @@ import {
} from 'dashboard/composables/store';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
@@ -35,8 +36,16 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto max-w-6xl mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingLinear">
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingLinear">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.LINEAR.HEADER')"
description=""
feature-name="linear_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
@@ -49,9 +58,6 @@ onMounted(() => {
message: $t('INTEGRATION_SETTINGS.LINEAR.DELETE.MESSAGE'),
}"
/>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -1,64 +1,80 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import { useI18n } from 'vue-i18n';
import BaseSettingsHeader from 'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
BaseSettingsHeader,
NextButton,
const props = defineProps({
integrationId: {
type: String,
required: true,
},
props: {
integrationId: {
type: String,
required: true,
},
showAddButton: {
type: Boolean,
default: false,
},
showAddButton: {
type: Boolean,
default: false,
},
emits: ['delete', 'add'],
setup(props) {
const { integration, isHookTypeInbox, hasConnectedHooks } =
useIntegrationHook(props.integrationId);
return { integration, isHookTypeInbox, hasConnectedHooks };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
}),
hookHeaders() {
return this.integration.visible_properties;
},
hooks() {
if (!this.hasConnectedHooks) {
return [];
}
const { hooks } = this.integration;
return hooks.map(hook => ({
...hook,
id: hook.id,
properties: this.hookHeaders.map(property =>
hook.settings[property] ? hook.settings[property] : '--'
),
}));
},
},
mounted() {},
methods: {
inboxName(hook) {
return hook.inbox ? hook.inbox.name : '';
},
},
};
});
defineEmits(['delete', 'add']);
const { t } = useI18n();
const { integration, isHookTypeInbox, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
const globalConfig = useMapGetter('globalConfig/get');
const searchQuery = ref('');
const hookHeaders = computed(() => {
const headers = [...(integration.value.visible_properties || [])];
if (isHookTypeInbox.value) {
headers.push(t('INTEGRATION_APPS.LIST.INBOX'));
}
headers.push(t('INTEGRATION_APPS.LIST.ACTIONS'));
return headers;
});
const hooks = computed(() => {
if (!hasConnectedHooks.value) {
return [];
}
const { hooks: integrationHooks } = integration.value;
const visibleProperties = integration.value.visible_properties || [];
return integrationHooks.map(hook => ({
...hook,
id: hook.id,
properties: visibleProperties.map(property =>
hook.settings[property] ? hook.settings[property] : '--'
),
}));
});
const filteredHooks = computed(() => {
const query = searchQuery.value?.trim() || '';
if (!query) return hooks.value;
const lowerQuery = query.toLowerCase();
return (
hooks.value?.filter(hook =>
hook.properties?.some(prop => prop?.toLowerCase().includes(lowerQuery))
) || []
);
});
const inboxName = hook => (hook.inbox ? hook.inbox.name : '');
</script>
<template>
<div class="flex flex-col flex-1 gap-8 overflow-auto">
<div class="flex flex-col flex-1 gap-4 overflow-auto">
<BaseSettingsHeader
:title="integration.name"
v-model:search-query="searchQuery"
:title="integration.name || ''"
:description="
$t(
`INTEGRATION_APPS.SIDEBAR_DESCRIPTION.${integration.name.toUpperCase()}`,
@@ -67,61 +83,64 @@ export default {
"
:feature-name="integrationId"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
:search-placeholder="$t('INTEGRATION_APPS.SEARCH_PLACEHOLDER')"
>
<template v-if="hooks?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('INTEGRATION_APPS.COUNT', { n: hooks.length }) }}
</span>
</template>
<template #actions>
<NextButton
v-if="showAddButton"
icon="i-lucide-circle-plus"
:label="$t('INTEGRATION_APPS.ADD_BUTTON')"
size="sm"
@click="$emit('add')"
/>
</template>
</BaseSettingsHeader>
<div class="w-full">
<table v-if="hasConnectedHooks">
<thead
class="[&>th]:font-semibold [&>th]:tracking-[1px] ltr:[&>th]:text-left rtl:[&>th]:text-right [&>th]:px-2.5 [&>th]:uppercase [&>th]:text-n-slate-12"
>
<th
v-for="hookHeader in hookHeaders"
:key="hookHeader"
class="ltr:!pl-0 rtl:!pr-0"
>
{{ hookHeader }}
</th>
<th v-if="isHookTypeInbox">
{{ $t('INTEGRATION_APPS.LIST.INBOX') }}
</th>
</thead>
<tbody>
<tr
v-for="hook in hooks"
:key="hook.id"
class="border-b border-n-weak [&>td]:p-2.5 [&>td]:text-n-slate-12"
>
<td
v-for="property in hook.properties"
:key="property"
class="ltr:!pl-0 rtl:!pr-0"
>
{{ property }}
</td>
<td v-if="isHookTypeInbox" class="break-words">
{{ inboxName(hook) }}
</td>
<td class="flex justify-end gap-1">
<NextButton
v-tooltip.top="$t('INTEGRATION_APPS.LIST.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete', hook)"
/>
</td>
</tr>
</tbody>
</table>
<BaseTable
v-if="hasConnectedHooks"
:headers="hookHeaders"
:items="filteredHooks"
:no-data-message="searchQuery ? $t('INTEGRATION_APPS.NO_RESULTS') : ''"
>
<template #row="{ items }">
<BaseTableRow v-for="hook in items" :key="hook.id" :item="hook">
<template #default>
<BaseTableCell
v-for="property in hook.properties"
:key="property"
>
<span class="text-body-main text-n-slate-12">
{{ property }}
</span>
</BaseTableCell>
<BaseTableCell v-if="isHookTypeInbox">
<span class="text-body-main text-n-slate-11 break-words">
{{ inboxName(hook) }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-12">
<div class="flex justify-end gap-3 flex-shrink-0">
<NextButton
v-tooltip.top="
$t('INTEGRATION_APPS.LIST.DELETE.BUTTON_TEXT')
"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', hook)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<p v-else class="flex flex-col items-center justify-center h-full">
{{
$t('INTEGRATION_APPS.NO_HOOK_CONFIGURED', {
@@ -11,7 +11,8 @@ import ButtonNext from 'next/button/Button.vue';
import notionClient from 'dashboard/api/notion_auth.js';
import Integration from './Integration.vue';
import Spinner from 'shared/components/Spinner.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const { t } = useI18n();
const store = useStore();
@@ -49,8 +50,16 @@ onMounted(() => {
</script>
<template>
<div class="flex-grow flex-shrink p-4 overflow-auto mx-auto">
<div v-if="integrationLoaded && !uiFlags.isCreatingNotion">
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingNotion">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.NOTION.HEADER')"
description=""
feature-name="notion_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
</template>
<template #body>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
@@ -72,9 +81,6 @@ onMounted(() => {
/>
</template>
</Integration>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -124,7 +124,7 @@ onMounted(() => {
</Integration>
<div
v-if="error"
class="flex items-center justify-center flex-1 p-6 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
>
<p class="text-n-ruby-9">
{{ t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
@@ -22,7 +22,7 @@ const { replaceInstallationName } = useBranding();
<template>
<div
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow flex-grow overflow-auto p-4"
class="outline outline-n-container outline-1 bg-n-card rounded-xl flex-grow overflow-auto p-4"
>
<div class="flex items-center justify-center">
<div class="flex h-16 w-16 items-center justify-center">
@@ -36,10 +36,10 @@ const { replaceInstallationName } = useBranding();
/>
</div>
<div class="flex flex-col justify-center m-0 mx-4 flex-1">
<h3 class="mb-1 text-xl font-medium text-n-slate-12">
<h3 class="mb-1 text-heading-1 text-n-slate-12">
{{ integration.name }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
<p class="text-n-slate-11 text-body-main">
{{ replaceInstallationName(integration.description) }}
</p>
</div>
@@ -6,7 +6,8 @@ import { useI18n } from 'vue-i18n';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
import Spinner from 'shared/components/Spinner.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const props = defineProps({
code: { type: String, default: '' },
@@ -76,32 +77,42 @@ onMounted(() => {
</script>
<template>
<div
v-if="integrationLoaded && !uiFlags.isCreatingSlack"
class="flex flex-col flex-1 overflow-auto gap-5 pt-1 pb-10"
>
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
<SettingsLayout :is-loading="!integrationLoaded || uiFlags.isCreatingSlack">
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.SLACK.HEADER')"
description=""
feature-name="slack_integration"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
/>
<SlackIntegrationHelpText :selected-channel-name="selectedChannelName" />
</div>
</div>
<div v-else class="flex items-center justify-center flex-1">
<Spinner size="" color-scheme="primary" />
</div>
</template>
<template #body>
<div class="space-y-5">
<Integration
:integration-id="integration.id"
:integration-logo="integration.logo"
:integration-name="integration.name"
:integration-description="integration.description"
:integration-enabled="integration.enabled"
:integration-action="integrationAction"
:action-button-text="$t('INTEGRATION_SETTINGS.SLACK.DELETE')"
:delete-confirmation-text="{
title: $t('INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.TITLE'),
message: $t(
'INTEGRATION_SETTINGS.SLACK.DELETE_CONFIRMATION.MESSAGE'
),
}"
/>
<div v-if="areHooksAvailable" class="flex-1">
<SelectChannelWarning
v-if="!isIntegrationHookEnabled"
:has-connected-a-channel="hasConnectedAChannel"
/>
<SlackIntegrationHelpText
:selected-channel-name="selectedChannelName"
/>
</div>
</div>
</template>
</SettingsLayout>
</template>
@@ -61,19 +61,19 @@ const updateIntegration = async () => {
<template>
<div
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
class="px-6 py-4 mb-4 outline outline-n-container outline-1 bg-n-card rounded-xl"
>
<div class="flex">
<div class="flex-shrink-0">
<div class="i-lucide-bell text-xl text-n-amber-11 mt-1" />
</div>
<div class="ml-3">
<p class="mb-1 text-base font-semibold text-n-slate-12">
<p class="mb-1 text-heading-2 text-n-slate-12">
{{
$t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.ATTENTION_REQUIRED')
}}
</p>
<div class="mt-2 text-sm text-n-slate-11 mb-3">
<div class="mt-2 text-body-main text-n-slate-11 mb-3">
<p v-dompurify-html="formattedErrorMessage" />
</div>
</div>
@@ -25,13 +25,16 @@ const formattedHelpText = computed(() => {
<template>
<div
class="flex-1 w-full px-6 py-5 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow"
class="flex-1 w-full px-6 py-5 outline outline-n-container outline-1 bg-n-card rounded-xl"
>
<div class="prose-lg max-w-5xl">
<h5 class="text-n-slate-12 tracking-tight">
<h5 class="text-n-slate-12 text-heading-1 tracking-tight">
{{ t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}
</h5>
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
<div
v-dompurify-html="formattedHelpText"
class="text-n-slate-11 text-body-main"
/>
</div>
</div>
</template>
@@ -2,7 +2,9 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { picoSearch } from '@scmmishra/pico-search';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import WebhookRow from './WebhookRow.vue';
@@ -14,6 +16,7 @@ export default {
SettingsLayout,
NextButton,
BaseSettingsHeader,
BaseTable,
NewWebhook,
EditWebhook,
WebhookRow,
@@ -29,6 +32,7 @@ export default {
showEditPopup: false,
showDeleteConfirmationPopup: false,
selectedWebHook: {},
searchQuery: '',
};
},
computed: {
@@ -39,6 +43,11 @@ export default {
integration() {
return this.$store.getters['integrations/getIntegration']('webhook');
},
filteredRecords() {
const query = this.searchQuery.trim();
if (!query) return this.records;
return picoSearch(this.records, query, ['name', 'url']);
},
tableHeaders() {
return [
this.$t(
@@ -103,44 +112,52 @@ export default {
<template #header>
<BaseSettingsHeader
v-if="integration.name"
v-model:search-query="searchQuery"
:title="integration.name"
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
:search-placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.SEARCH_PLACEHOLDER')
"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.WEBHOOK.COUNT', { n: records.length })
}}
</span>
</template>
<template #actions>
<NextButton
blue
icon="i-lucide-circle-plus"
:label="$t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11 last:text-right last:pr-4"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak flex-1 text-n-slate-12">
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('INTEGRATION_SETTINGS.WEBHOOK.NO_RESULTS') : ''
"
>
<template #row="{ items }">
<WebhookRow
v-for="(webHookItem, index) in records"
v-for="(webHookItem, index) in items"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'vue-i18n';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -35,45 +35,48 @@ const subscribedEvents = computed(() => {
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex gap-2 font-medium break-words text-n-slate-12">
<template v-if="webhook.name">
{{ webhook.name }}
<span class="text-n-slate-11">
<BaseTableRow :item="webhook">
<template #default>
<BaseTableCell>
<div class="flex gap-2 font-medium break-words text-n-slate-12">
<template v-if="webhook.name">
{{ webhook.name }}
<span class="text-n-slate-11">
{{ webhook.url }}
</span>
</template>
<template v-else>
{{ webhook.url }}
</template>
</div>
<div class="block mt-1 text-sm text-n-slate-11">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
</template>
<template v-else>
{{ webhook.url }}
</template>
</div>
<div class="block mt-1 text-sm text-n-slate-11">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<ShowMore :text="subscribedEvents" :limit="60" />
</div>
</td>
<td class="py-4 min-w-xs">
<div class="flex justify-end gap-1">
<Button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
icon="i-lucide-pen"
slate
xs
faded
@click="emit('edit', webhook)"
/>
<Button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="emit('delete', webhook, index)"
/>
</div>
</td>
</tr>
<ShowMore :text="subscribedEvents" :limit="60" />
</div>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex justify-end gap-3 flex-shrink-0">
<Button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', webhook)"
/>
<Button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')
"
icon="i-woot-bin"
slate
sm
@click="emit('delete', webhook, index)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -6,7 +6,6 @@ import Index from './Index.vue';
import Webhook from './Webhooks/Index.vue';
import DashboardApps from './DashboardApps/Index.vue';
import Slack from './Slack.vue';
import SettingsContent from '../Wrapper.vue';
import Linear from './Linear.vue';
import Notion from './Notion.vue';
import Shopify from './Shopify.vue';
@@ -49,28 +48,7 @@ export default {
},
{
path: frontendURL('accounts/:accountId/settings/integrations'),
component: SettingsContent,
props: params => {
const integrationId = params.params?.integration_id;
const hideHeader = ['dialogflow'].includes(integrationId);
// Don't show header
if (hideHeader) {
return {};
}
const showBackButton = params.name !== 'settings_integrations';
const backUrl =
params.name === 'settings_integrations_integration'
? { name: 'settings_integrations' }
: '';
return {
headerTitle: 'INTEGRATION_SETTINGS.HEADER',
icon: 'flash-on',
showBackButton,
backUrl,
};
},
component: SettingsWrapper,
children: [
{
path: 'slack',
@@ -3,12 +3,18 @@ import { useAlert } from 'dashboard/composables';
import { computed, onBeforeMount, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { picoSearch } from '@scmmishra/pico-search';
import AddLabel from './AddLabel.vue';
import EditLabel from './EditLabel.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
const getters = useStoreGetters();
const store = useStore();
@@ -19,8 +25,15 @@ const showAddPopup = ref(false);
const showEditPopup = ref(false);
const showDeleteConfirmationPopup = ref(false);
const selectedLabel = ref({});
const searchQuery = ref('');
const records = computed(() => getters['labels/getLabels'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['title', 'description']);
});
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
const deleteMessage = computed(() => ` ${selectedLabel.value.title}?`);
@@ -72,6 +85,7 @@ const tableHeaders = computed(() => {
t('LABEL_MGMT.LIST.TABLE_HEADER.NAME'),
t('LABEL_MGMT.LIST.TABLE_HEADER.DESCRIPTION'),
t('LABEL_MGMT.LIST.TABLE_HEADER.COLOR'),
t('LABEL_MGMT.LIST.TABLE_HEADER.ACTION'),
];
});
@@ -89,73 +103,86 @@ onBeforeMount(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('LABEL_MGMT.HEADER')"
:description="$t('LABEL_MGMT.DESCRIPTION')"
:link-text="$t('LABEL_MGMT.LEARN_MORE')"
:search-placeholder="$t('LABEL_MGMT.SEARCH_PLACEHOLDER')"
feature-name="labels"
>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('LABEL_MGMT.COUNT', { n: records.length }) }}
</span>
</template>
<template #actions>
<Button
icon="i-lucide-circle-plus"
:label="$t('LABEL_MGMT.HEADER_BTN_TXT')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full overflow-x-auto divide-y divide-n-weak">
<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="(label, index) in records" :key="label.title">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<span class="mb-1 font-medium break-words text-n-slate-12">
{{ label.title }}
</span>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ label.description }}</td>
<td class="py-4 leading-6 ltr:pr-4 rtl:pl-4">
<div class="flex items-center">
<span
class="w-4 h-4 mr-1 border border-solid rounded rtl:mr-0 rtl:ml-1 border-n-weak"
:style="{ backgroundColor: label.color }"
/>
{{ label.color }}
</div>
</td>
<td class="py-4 min-w-xs">
<div class="flex gap-1 justify-end">
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.EDIT')"
icon="i-lucide-pen"
slate
xs
faded
:is-loading="loading[label.id]"
@click="openEditPopup(label)"
/>
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.DELETE')"
icon="i-lucide-trash-2"
xs
ruby
faded
:is-loading="loading[label.id]"
@click="openDeletePopup(label, index)"
/>
</div>
</td>
</tr>
</tbody>
</table>
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('LABEL_MGMT.NO_RESULTS') : $t('LABEL_MGMT.LIST.404')
"
>
<template #row="{ items }">
<BaseTableRow v-for="label in items" :key="label.title" :item="label">
<template #default>
<BaseTableCell>
<span class="text-body-main text-n-slate-12">
{{ label.title }}
</span>
</BaseTableCell>
<BaseTableCell>
<span class="text-body-main text-n-slate-11">
{{ label.description }}
</span>
</BaseTableCell>
<BaseTableCell>
<div class="flex items-center">
<span
class="w-4 h-4 ltr:mr-2 rtl:ml-2 border border-solid rounded border-n-weak"
:style="{ backgroundColor: label.color }"
/>
<span class="text-body-main text-n-slate-12">
{{ label.color }}
</span>
</div>
</BaseTableCell>
<BaseTableCell align="end">
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.EDIT')"
icon="i-woot-edit-pen"
slate
sm
:is-loading="loading[label.id]"
@click="openEditPopup(label)"
/>
<Button
v-tooltip.top="$t('LABEL_MGMT.FORM.DELETE')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[label.id]"
@click="openDeletePopup(label)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
</template>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
@@ -1,5 +1,6 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
import MacrosTableRow from './MacrosTableRow.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import SettingsLayout from '../SettingsLayout.vue';
@@ -7,6 +8,7 @@ import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTable } from 'dashboard/components-next/table';
const getters = useStoreGetters();
const store = useStore();
@@ -14,10 +16,17 @@ const { t } = useI18n();
const showDeleteConfirmationPopup = ref(false);
const selectedMacro = ref({});
const searchQuery = ref('');
const records = computed(() => getters['macros/getMacros'].value);
const uiFlags = computed(() => getters['macros/getUIFlags'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['name']);
});
const deleteMessage = computed(() => ` ${selectedMacro.value.name}?`);
onMounted(() => {
@@ -53,6 +62,7 @@ const tableHeaders = computed(() => {
t('MACROS.LIST.TABLE_HEADER.CREATED BY'),
t('MACROS.LIST.TABLE_HEADER.LAST_UPDATED_BY'),
t('MACROS.LIST.TABLE_HEADER.VISIBILITY'),
t('MACROS.LIST.TABLE_HEADER.ACTIONS'),
];
});
</script>
@@ -67,41 +77,42 @@ const tableHeaders = computed(() => {
>
<template #header>
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('MACROS.HEADER')"
:description="$t('MACROS.DESCRIPTION')"
:link-text="$t('MACROS.LEARN_MORE')"
:search-placeholder="$t('MACROS.SEARCH_PLACEHOLDER')"
feature-name="macros"
>
<template v-if="records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('MACROS.COUNT', { n: records.length }) }}
</span>
</template>
<template #actions>
<router-link :to="{ name: 'macros_new' }">
<Button
icon="i-lucide-circle-plus"
:label="$t('MACROS.HEADER_BTN_TXT')"
/>
<Button :label="$t('MACROS.HEADER_BTN_TXT')" size="sm" />
</router-link>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table class="min-w-full divide-y divide-n-weak">
<thead>
<th
v-for="thHeader in tableHeaders"
:key="thHeader"
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-n-slate-11"
>
{{ thHeader }}
</th>
</thead>
<tbody class="divide-y divide-n-weak text-n-slate-11">
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
searchQuery ? $t('MACROS.NO_RESULTS') : $t('MACROS.LIST.404')
"
>
<template #row="{ items }">
<MacrosTableRow
v-for="(macro, index) in records"
:key="index"
v-for="macro in items"
:key="macro.id"
:macro="macro"
@delete="openDeletePopup(macro)"
/>
</tbody>
</table>
</template>
</BaseTable>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
@@ -128,7 +128,7 @@ const saveMacro = async macroData => {
</script>
<template>
<div class="flex flex-col flex-1 h-full overflow-auto">
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
<woot-loading-state
v-if="uiFlags.isFetchingItem"
:message="t('MACROS.EDITOR.LOADING')"
@@ -108,9 +108,9 @@ export default {
</script>
<template>
<div class="flex flex-col w-full h-auto md:flex-row md:h-full">
<div class="flex flex-col w-full h-auto lg:flex-row lg:h-full">
<div
class="flex-1 w-full h-full max-h-full px-12 py-4 overflow-y-auto md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
class="flex-1 w-full h-full max-h-full ltr:pl-12 ltr:pr-6 rtl:pl-6 rtl:pr-12 py-4 overflow-y-auto lg:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size"
>
<MacroNodes
v-model="macro.actions"
@@ -121,7 +121,7 @@ export default {
@reset-action="resetNode"
/>
</div>
<div class="w-full md:w-1/3 pb-4">
<div class="w-full lg:w-1/3 pb-4">
<MacroProperties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
@@ -1,9 +1,11 @@
<script>
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
export default {
components: {
NextButton,
Icon,
},
inject: ['v$'],
props: {
@@ -53,56 +55,57 @@ export default {
</p>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
:class="isActive('global')"
@click="onUpdateVisibility('global')"
>
<fluent-icon
v-if="macroVisibility === 'global'"
icon="checkmark-circle"
type="solid"
class="absolute text-n-brand top-2 right-2"
/>
<p
class="block m-0 text-sm font-medium leading-[1.8] text-n-slate-12"
>
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL') }}
</p>
<p class="text-xs text-n-slate-11">
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
<p class="block m-0 text-heading-3 text-n-slate-12 line-clamp-1">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.LABEL') }}
</p>
<Icon
v-if="macroVisibility === 'global'"
icon="i-lucide-circle-check-big"
class="text-n-brand size-4"
/>
</div>
<p class="text-n-slate-11 text-label-small">
{{ $t('MACROS.EDITOR.VISIBILITY.GLOBAL.DESCRIPTION') }}
</p>
</button>
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
class="p-2 relative rounded-md border border-solid justify-between items-start gap-2 flex flex-col text-start cursor-default"
:class="isActive('personal')"
@click="onUpdateVisibility('personal')"
>
<fluent-icon
v-if="macroVisibility === 'personal'"
icon="checkmark-circle"
type="solid"
class="absolute text-n-brand top-2 right-2"
/>
<p
class="block m-0 text-sm font-medium leading-[1.8] text-n-slate-12"
>
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL') }}
</p>
<p class="text-xs text-n-slate-11">
<div class="flex items-center gap-2 min-w-0 justify-between w-full">
<p class="block m-0 text-heading-3 text-n-slate-12 line-clamp-1">
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.LABEL') }}
</p>
<Icon
v-if="macroVisibility === 'personal'"
icon="i-lucide-circle-check-big"
class="text-n-brand size-4"
/>
</div>
<p class="text-n-slate-11 text-label-small">
{{ $t('MACROS.EDITOR.VISIBILITY.PERSONAL.DESCRIPTION') }}
</p>
</button>
</div>
<div
class="mt-2 flex items-start p-2 bg-n-slate-3 dark:bg-n-solid-3 rounded-md"
class="mt-2 flex items-start p-2 bg-n-alpha-1 gap-2 dark:bg-n-solid-3 rounded-md"
>
<fluent-icon icon="info" size="16" class="flex-shrink-0 mt-0.5" />
<p class="ml-2 rtl:ml-0 rtl:mr-2 mb-0 text-n-slate-11">
<Icon
icon="i-lucide-info"
class="flex-shrink-0 mt-0.5 size-4 text-n-slate-11"
/>
<p class="mb-0 text-n-slate-11 text-body-para">
{{ $t('MACROS.ORDER_INFO') }}
</p>
</div>
</div>
<div class="mt-auto w-full">
<div class="mt-4 w-full">
<NextButton
blue
solid
@@ -4,6 +4,7 @@ import Avatar from 'next/avatar/Avatar.vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
const props = defineProps({
macro: {
@@ -34,41 +35,71 @@ const visibilityLabel = computed(() => {
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4 truncate">{{ macro.name }}</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.created_by" class="flex items-center">
<Avatar :name="createdByName" :size="24" rounded-full />
<span class="mx-2">{{ createdByName }}</span>
</div>
<div v-else>--</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div v-if="macro.updated_by" class="flex items-center">
<Avatar :name="updatedByName" :size="24" rounded-full />
<span class="mx-2">{{ updatedByName }}</span>
</div>
<div v-else>--</div>
</td>
<td class="py-4 ltr:pr-4 rtl:pl-4">{{ visibilityLabel }}</td>
<td class="py-4 flex justify-end gap-1">
<router-link :to="{ name: 'macros_edit', params: { macroId: macro.id } }">
<Button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
icon="i-lucide-pen"
slate
xs
faded
/>
</router-link>
<Button
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
icon="i-lucide-trash-2"
xs
ruby
faded
@click="$emit('delete')"
/>
</td>
</tr>
<BaseTableRow :item="macro">
<template #default>
<BaseTableCell class="max-w-0 min-w-0">
<span class="text-body-main text-n-slate-12 truncate block">
{{ macro.name }}
</span>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<div v-if="macro.created_by" class="flex items-center gap-2 min-w-0">
<Avatar
:name="createdByName"
:size="24"
rounded-full
class="flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ createdByName }}
</span>
</div>
<span v-else class="text-body-main text-n-slate-11">--</span>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<div v-if="macro.updated_by" class="flex items-center gap-2 min-w-0">
<Avatar
:name="updatedByName"
:size="24"
rounded-full
class="flex-shrink-0"
/>
<span class="text-body-main text-n-slate-12 truncate">
{{ updatedByName }}
</span>
</div>
<span v-else class="text-body-main text-n-slate-11">--</span>
</BaseTableCell>
<BaseTableCell class="max-w-0">
<span class="text-body-main text-n-slate-12 whitespace-nowrap">
{{ visibilityLabel }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-24">
<div class="flex gap-3 justify-end flex-shrink-0">
<router-link
:to="{ name: 'macros_edit', params: { macroId: macro.id } }"
>
<Button
v-tooltip.top="$t('MACROS.EDIT.TOOLTIP')"
icon="i-woot-edit-pen"
slate
sm
/>
</router-link>
<Button
v-tooltip.top="$t('MACROS.DELETE.TOOLTIP')"
icon="i-woot-bin"
slate
sm
@click="$emit('delete')"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
@@ -19,9 +19,7 @@ const onChange = (id, value) => {
<template>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-n-slate-12"
>
<label class="flex justify-between pb-1 text-heading-3 text-n-slate-12">
{{ label }}
</label>
<div class="flex flex-col gap-3 mt-2">
@@ -37,7 +35,7 @@ const onChange = (id, value) => {
@update="onChange"
/>
<label
class="text-sm font-normal text-n-slate-12"
class="text-body-main text-n-slate-12"
:for="`checkbox-condition-${item.value}`"
>
{{ item.label }}
@@ -32,9 +32,7 @@ defineProps({
>
<div class="flex flex-col gap-2 items-center w-full rounded-t-[5px]">
<div class="grid grid-cols-[1fr_auto] items-center w-full gap-1">
<div
class="overflow-hidden text-base font-medium text-n-slate-12 text-left"
>
<div class="overflow-hidden text-heading-2 text-n-slate-12 text-start">
<span class="block truncate">{{ title }}</span>
</div>
<input
@@ -44,9 +42,7 @@ defineProps({
class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand"
/>
</div>
<span
class="text-n-slate-12 text-sm line-clamp-2 leading-[1.4] text-start"
>
<span class="text-n-slate-11 line-clamp-2 text-body-para text-start">
{{ description }}
</span>
</div>
@@ -17,7 +17,8 @@ import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import SectionLayout from '../account/components/SectionLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import Policy from 'dashboard/components/policy.vue';
@@ -29,7 +30,7 @@ import {
export default {
components: {
MessageSignature,
FormSection,
SectionLayout,
FontSize,
UserLanguageSelect,
UserProfilePicture,
@@ -41,6 +42,7 @@ export default {
AudioNotifications,
AccessToken,
MfaSettingsCard,
BaseSettingsHeader,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@@ -203,26 +205,27 @@ export default {
</script>
<template>
<div class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md">
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-n-slate-12">
{{ $t('PROFILE_SETTINGS.TITLE') }}
</h2>
<UserProfilePicture
:src="avatarUrl"
:name="name"
@change="updateProfilePicture"
@delete="deleteProfilePicture"
/>
<UserBasicDetails
:name="name"
:display-name="displayName"
:email="email"
:email-enabled="!globalConfig.disableUserProfileUpdate"
@update-user="updateProfile"
/>
</div>
<FormSection
<div class="grid max-w-2xl ltr:mr-auto rtl:ml-auto">
<BaseSettingsHeader :title="$t('PROFILE_SETTINGS.TITLE')" description="" />
<SectionLayout title="" description="" class="!pt-0">
<div class="flex flex-col gap-6">
<UserProfilePicture
:src="avatarUrl"
:name="name"
@change="updateProfilePicture"
@delete="deleteProfilePicture"
/>
<UserBasicDetails
:name="name"
:display-name="displayName"
:email="email"
:email-enabled="!globalConfig.disableUserProfileUpdate"
@update-user="updateProfile"
/>
</div>
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
:description="
replaceInstallationName(
@@ -230,22 +233,25 @@ export default {
)
"
>
<FontSize
:value="currentFontSize"
:label="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.NOTE')
"
@change="updateFontSize"
/>
<UserLanguageSelect
:label="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
</FormSection>
<FormSection
<div class="flex flex-col gap-6 items-start">
<FontSize
:value="currentFontSize"
:label="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.NOTE')
"
@change="updateFontSize"
/>
<UserLanguageSelect
:label="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.LANGUAGE.NOTE')
"
/>
</div>
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.NOTE')"
>
@@ -253,8 +259,9 @@ export default {
:message-signature="messageSignature"
@update-signature="updateSignature"
/>
</FormSection>
<FormSection
</SectionLayout>
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SEND_MESSAGE.NOTE')"
>
@@ -282,36 +289,45 @@ export default {
/>
</button>
</div>
</FormSection>
<FormSection
</SectionLayout>
<SectionLayout
v-if="!globalConfig.disableUserProfileUpdate"
with-border
:title="$t('PROFILE_SETTINGS.FORM.PASSWORD_SECTION.TITLE')"
description=""
>
<ChangePassword />
</FormSection>
<FormSection
</SectionLayout>
<SectionLayout
v-if="isMfaEnabled"
with-border
:title="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.SECURITY_SECTION.NOTE')"
>
<MfaSettingsCard />
</FormSection>
</SectionLayout>
<Policy :permissions="audioNotificationPermissions">
<FormSection
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.TITLE')"
:description="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.NOTE')
"
>
<AudioNotifications />
</FormSection>
</SectionLayout>
</Policy>
<Policy :permissions="notificationPermissions">
<FormSection :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')"
description=""
>
<NotificationPreferences />
</FormSection>
</SectionLayout>
</Policy>
<FormSection
<SectionLayout
with-border
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
@@ -322,6 +338,6 @@ export default {
@on-copy="onCopyToken"
@on-reset="resetAccessToken"
/>
</FormSection>
</SectionLayout>
</div>
</template>
@@ -8,6 +8,7 @@ import { useAlert } from 'dashboard/composables';
import MfaStatusCard from './MfaStatusCard.vue';
import MfaSetupWizard from './MfaSetupWizard.vue';
import MfaManagementActions from './MfaManagementActions.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const { t } = useI18n();
const router = useRouter();
@@ -130,20 +131,14 @@ const regenerateBackupCodes = async ({ otpCode }) => {
</script>
<template>
<div
class="grid py-16 px-5 font-inter mx-auto gap-16 sm:max-w-screen-md w-full"
>
<!-- Page Header -->
<div class="flex flex-col gap-6">
<h2 class="text-2xl font-medium text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11">
{{ $t('MFA_SETTINGS.SUBTITLE') }}
</p>
</div>
<div class="grid w-full">
<BaseSettingsHeader
:title="$t('MFA_SETTINGS.TITLE')"
:description="$t('MFA_SETTINGS.SUBTITLE')"
:back-button-label="$t('PROFILE_SETTINGS.TITLE')"
/>
<div class="grid gap-4 w-full">
<div class="grid gap-4 w-full mt-4">
<!-- MFA Status Card -->
<MfaStatusCard
:mfa-enabled="mfaEnabled"
@@ -26,11 +26,11 @@ const navigateToMfa = () => {
icon="i-lucide-lock-keyhole"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<h5 class="text-sm font-semibold text-n-slate-12">
<h5 class="text-heading-3 text-n-slate-12">
{{ $t('MFA_SETTINGS.TITLE') }}
</h5>
</div>
<p class="text-sm text-n-slate-11">
<p class="text-body-para text-n-slate-11">
{{ $t('MFA_SETTINGS.DESCRIPTION') }}
</p>
</div>
@@ -167,7 +167,7 @@ export default {
:span="7"
label="`${$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPE_TITLE')}`"
>
<span class="text-sm font-normal normal-case text-n-slate-11">
<span class="text-heading-3 normal-case text-n-slate-12">
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TYPE_TITLE') }}
</span>
</TableHeaderCell>
@@ -175,7 +175,7 @@ export default {
:span="2"
label="`${$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.EMAIL')}`"
>
<span class="text-sm font-medium normal-case text-n-slate-12">
<span class="text-heading-3 normal-case text-n-slate-12">
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.EMAIL') }}
</span>
</TableHeaderCell>
@@ -185,7 +185,7 @@ export default {
>
<div class="flex items-center justify-between gap-1">
<span
class="text-sm font-medium normal-case text-n-slate-12 whitespace-nowrap"
class="text-heading-3 normal-case text-n-slate-12 whitespace-nowrap"
>
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.PUSH') }}
</span>
@@ -202,7 +202,7 @@ export default {
<div
class="flex flex-row items-start gap-2 col-span-7 px-0 py-2 text-sm tracking-[0.5] rtl:text-right"
>
<span class="text-sm text-n-slate-12">
<span class="text-body-main text-n-slate-12">
{{ $t(notification.label) }}
</span>
</div>
@@ -225,7 +225,7 @@ export default {
</div>
<!-- Layout for mobile devices -->
<div class="flex flex-col gap-6 sm:hidden">
<span class="text-sm font-medium normal-case text-n-slate-12">
<span class="text-heading-3 text-n-slate-12">
{{ $t('PROFILE_SETTINGS.FORM.EMAIL_NOTIFICATIONS_SECTION.TITLE') }}
</span>
<div class="flex flex-col gap-4">
@@ -240,14 +240,14 @@ export default {
:is-checked="checkFlagStatus('email', notification.value)"
@update="handleEmailInput"
/>
<span class="text-sm text-n-slate-12">{{
<span class="text-body-main text-n-slate-12">{{
$t(notification.label)
}}</span>
</div>
</div>
<div class="flex items-center justify-start gap-2">
<span class="text-sm font-medium normal-case text-n-slate-12">
<span class="text-heading-3 text-n-slate-12">
{{ $t('PROFILE_SETTINGS.FORM.PUSH_NOTIFICATIONS_SECTION.TITLE') }}
</span>
</div>
@@ -264,7 +264,7 @@ export default {
:is-checked="checkFlagStatus('push', notification.value)"
@update="handlePushInput"
/>
<span class="text-sm text-n-slate-12">{{
<span class="text-body-main text-n-slate-12">{{
$t(notification.label)
}}</span>
</div>
@@ -280,7 +280,7 @@ export default {
class="flex-shrink-0 text-n-slate-12"
size="18"
/>
<span class="text-sm text-n-slate-12">
<span class="text-body-main text-n-slate-12">
{{ $t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.BROWSER_PERMISSION') }}
</span>
</div>
@@ -1,7 +1,7 @@
import { frontendURL } from '../../../../helper/URLHelper';
import { parseBoolean } from '@chatwoot/utils';
import SettingsContent from './Wrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
import Index from './Index.vue';
import MfaSettings from './MfaSettings.vue';
@@ -13,7 +13,7 @@ export default {
meta: {
permissions: ['administrator', 'agent', 'custom_role'],
},
component: SettingsContent,
component: SettingsWrapper,
children: [
{
path: 'settings',
@@ -18,17 +18,22 @@ defineProps({
</script>
<template>
<section class="flex flex-col gap-1 pt-10 pb-5">
<section class="flex flex-col gap-1 pt-6 pb-5">
<div v-if="hasBackButton">
<BackButton compact />
</div>
<div class="flex justify-between w-full gap-5">
<div class="flex flex-col gap-2">
<div>
<span class="text-xl font-medium text-n-slate-12">
<div class="flex flex-col gap-3 items-start">
<span
class="text-heading-1 text-n-slate-12 min-h-10 flex items-center"
>
{{ headerTitle }}
</span>
<p v-if="headerDescription" class="text-n-slate-12 mt-2">
<p
v-if="headerDescription"
class="text-n-slate-11 mb-0 line-clamp-5 sm:line-clamp-none text-body-main"
>
{{ headerDescription }}
</p>
</div>
@@ -1,83 +1,7 @@
<template>
<div class="reports--wrapper overflow-auto bg-n-surface-1 w-full px-6">
<div class="max-w-[60rem] mx-auto pb-12">
<div class="overflow-auto bg-n-surface-1 w-full px-6">
<div class="max-w-5xl mx-auto pb-12">
<router-view />
</div>
</div>
</template>
<style scoped lang="scss">
.reports--wrapper {
:deep() {
.multiselect--disabled {
@apply opacity-50 border border-n-weak rounded-md cursor-not-allowed;
}
.multiselect__content-wrapper {
@apply bg-n-solid-2 border border-n-weak text-n-slate-12;
}
.multiselect__tags {
@apply bg-n-slate-1 border border-n-weak m-0 min-h-[2.875rem] pt-0;
input[type='text'] {
@apply bg-n-alpha-3 border-n-weak placeholder:text-n-slate-11 min-h-[2.375rem] h-[2.375rem] px-0.5 py-0 text-sm;
}
}
.multiselect__placeholder {
@apply text-n-slate-11;
}
.multiselect__select {
@apply min-h-0;
}
.multiselect__single {
@apply bg-n-alpha-3 text-n-slate-11;
}
.multiselect__input {
@apply text-sm h-[2.375rem] mb-0 py-0;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-3 !border-n-weak text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.mx-input-wrapper {
@apply bg-n-alpha-3 border-n-weak text-n-slate-12 rounded-lg text-sm;
input {
@apply border-n-weak text-sm;
}
}
.multiselect__option {
@apply flex items-center;
}
.mx-datepicker {
.mx-input {
background-color: transparent !important;
}
.mx-input-wrapper input::placeholder {
@apply text-n-slate-11;
}
.mx-input-wrapper input {
@apply text-n-slate-11;
}
}
.multiselect--active:not(.multiselect--above) .multiselect__current,
.multiselect--active:not(.multiselect--above) .multiselect__input,
.multiselect--active:not(.multiselect--above) .multiselect__tags {
@apply rounded-b-none;
}
}
}
</style>
@@ -31,10 +31,7 @@ const showPaywall = computed(() => shouldShowPaywall('saml'));
</script>
<template>
<SettingsLayout
class="max-w-2xl mx-auto"
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
>
<SettingsLayout :loading-message="$t('ATTRIBUTES_MGMT.LOADING')">
<template #header>
<BaseSettingsHeader
:title="$t('SECURITY_SETTINGS.TITLE')"
@@ -27,7 +27,7 @@ const openBilling = () => {
<template>
<div
class="w-full max-w-[60rem] mx-auto h-full max-h-[28rem] grid place-content-center"
class="w-full max-w-5xl mx-auto h-full max-h-[28rem] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
@@ -173,6 +173,7 @@ onMounted(() => {
:description="t('SECURITY_SETTINGS.SAML.NOTE')"
beta
:hide-content="!hasFeature || !isEnabled || isLoading"
class="max-w-2xl ltr:mr-auto rtl:ml-auto"
>
<template #headerActions>
<div class="flex justify-end">
@@ -1,25 +1,34 @@
<script>
import AddSLA from './AddSLA.vue';
import SettingsLayout from '../SettingsLayout.vue';
import SLAEmptyState from './components/SLAEmptyState.vue';
import SLAHeader from './components/SLAHeader.vue';
import SLAListItem from './components/SLAListItem.vue';
import SLAListItemLoading from './components/SLAListItemLoading.vue';
import SLAPaywallEnterprise from './components/SLAPaywallEnterprise.vue';
import BaseSettingsHeader from 'dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue';
import SLAPaywallEnterprise from './SLAPaywallEnterprise.vue';
import {
BaseTable,
BaseTableRow,
BaseTableCell,
} from 'dashboard/components-next/table';
import WootLabel from 'dashboard/components-next/label/Label.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { mapGetters } from 'vuex';
import { convertSecondsToTimeUnit } from '@chatwoot/utils';
import { useAlert } from 'dashboard/composables';
import { picoSearch } from '@scmmishra/pico-search';
export default {
components: {
AddSLA,
SettingsLayout,
SLAEmptyState,
SLAHeader,
SLAListItem,
SLAListItemLoading,
BaseSettingsHeader,
SLAPaywallEnterprise,
BaseTable,
BaseTableRow,
BaseTableCell,
WootLabel,
Icon,
NextButton,
},
data() {
return {
@@ -27,6 +36,7 @@ export default {
showAddPopup: false,
showDeleteConfirmationPopup: false,
selectedResponse: {},
searchQuery: '',
};
},
computed: {
@@ -53,6 +63,21 @@ export default {
isSuperAdmin() {
return this.currentUser.type === 'SuperAdmin';
},
tableHeaders() {
return [
this.$t('SLA.LIST.TABLE_HEADER.SLA'),
this.$t('SLA.LIST.TABLE_HEADER.BUSINESS_HOURS'),
this.$t('SLA.LIST.RESPONSE_TYPES.SHORT_HAND.FRT'),
this.$t('SLA.LIST.RESPONSE_TYPES.SHORT_HAND.NRT'),
this.$t('SLA.LIST.RESPONSE_TYPES.SHORT_HAND.RT'),
this.$t('INTEGRATION_APPS.LIST.ACTIONS'),
];
},
filteredRecords() {
const query = this.searchQuery.trim();
if (!query) return this.records;
return picoSearch(this.records, query, ['name', 'description']);
},
},
mounted() {
this.$store.dispatch('sla/get');
@@ -117,10 +142,29 @@ export default {
:loading-message="$t('SLA.LOADING')"
>
<template #header>
<SLAHeader :show-actions="records.length > 0" @add="openAddPopup" />
</template>
<template #loading>
<SLAListItemLoading v-for="ii in 2" :key="ii" class="mb-3" />
<BaseSettingsHeader
v-model:search-query="searchQuery"
:title="$t('SLA.HEADER')"
:description="$t('SLA.DESCRIPTION')"
:link-text="$t('SLA.LEARN_MORE')"
:search-placeholder="
isBehindAPaywall ? '' : $t('SLA.SEARCH_PLACEHOLDER')
"
feature-name="sla"
>
<template v-if="!isBehindAPaywall && records?.length" #count>
<span class="text-body-main text-n-slate-11">
{{ $t('SLA.COUNT', { n: records.length }) }}
</span>
</template>
<template v-if="!isBehindAPaywall" #actions>
<NextButton
:label="$t('SLA.ADD_ACTION')"
size="sm"
@click="openAddPopup"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<SLAPaywallEnterprise
@@ -129,24 +173,130 @@ export default {
:is-on-chatwoot-cloud="isOnChatwootCloud"
@upgrade="onClickCTA"
/>
<SLAEmptyState
v-else-if="!records.length"
@primary-action="openAddPopup"
/>
<div v-else class="flex flex-col w-full h-full gap-3">
<SLAListItem
v-for="sla in records"
:key="sla.title"
:sla-name="sla.name"
:description="sla.description"
:first-response="displayTime(sla.first_response_time_threshold)"
:next-response="displayTime(sla.next_response_time_threshold)"
:resolution-time="displayTime(sla.resolution_time_threshold)"
:has-business-hours="sla.only_during_business_hours"
:is-loading="loading[sla.id]"
@delete="openDeletePopup(sla)"
/>
</div>
<BaseTable
v-else
:headers="tableHeaders"
:items="filteredRecords"
:no-data-message="
!records.length
? $t('SLA.LIST.404')
: searchQuery && !filteredRecords.length
? $t('SLA.SEARCH.NO_RESULTS')
: ''
"
>
<template #header-2>
<div class="flex items-center gap-1">
<span class="text-heading-3">
{{ $t('SLA.LIST.RESPONSE_TYPES.SHORT_HAND.FRT') }}
</span>
<Icon
v-tooltip.left="$t('SLA.LIST.RESPONSE_TYPES.FRT')"
icon="i-lucide-info"
class="size-3.5 text-n-slate-10 cursor-help"
/>
</div>
</template>
<template #header-3>
<div class="flex items-center gap-1">
<span class="text-heading-3">
{{ $t('SLA.LIST.RESPONSE_TYPES.SHORT_HAND.NRT') }}
</span>
<Icon
v-tooltip.left="$t('SLA.LIST.RESPONSE_TYPES.NRT')"
icon="i-lucide-info"
class="size-3.5 text-n-slate-10 cursor-help"
/>
</div>
</template>
<template #header-4>
<div class="flex items-center gap-1">
<span class="text-heading-3">
{{ $t('SLA.LIST.RESPONSE_TYPES.SHORT_HAND.RT') }}
</span>
<Icon
v-tooltip.left="$t('SLA.LIST.RESPONSE_TYPES.RT')"
icon="i-lucide-info"
class="size-3.5 text-n-slate-10 cursor-help"
/>
</div>
</template>
<template #row="{ items }">
<BaseTableRow v-for="sla in items" :key="sla.id" :item="sla">
<template #default>
<BaseTableCell>
<div class="flex flex-col gap-1 min-w-0">
<span class="text-body-main text-n-slate-12 truncate">
{{ sla.name }}
</span>
<span class="text-body-main text-n-slate-11 line-clamp-1">
{{ sla.description }}
</span>
</div>
</BaseTableCell>
<BaseTableCell class="w-40">
<WootLabel
:label="
sla.only_during_business_hours
? $t('SLA.LIST.BUSINESS_HOURS_ON')
: $t('SLA.LIST.BUSINESS_HOURS_OFF')
"
:color="sla.only_during_business_hours ? 'teal' : 'slate'"
compact
>
<template #icon>
<Icon
:icon="
sla.only_during_business_hours
? 'i-lucide-alarm-clock-check'
: 'i-lucide-alarm-clock-off'
"
class="size-3.5"
:class="
sla.only_during_business_hours
? 'text-n-teal-11'
: 'text-n-slate-11'
"
/>
</template>
</WootLabel>
</BaseTableCell>
<BaseTableCell align="start" class="w-24">
<span class="text-body-main text-n-slate-12">
{{ displayTime(sla.first_response_time_threshold) }}
</span>
</BaseTableCell>
<BaseTableCell align="start" class="w-24">
<span class="text-body-main text-n-slate-12">
{{ displayTime(sla.next_response_time_threshold) }}
</span>
</BaseTableCell>
<BaseTableCell align="start" class="w-24">
<span class="text-body-main text-n-slate-12">
{{ displayTime(sla.resolution_time_threshold) }}
</span>
</BaseTableCell>
<BaseTableCell align="end" class="w-12">
<div class="flex justify-end">
<NextButton
v-tooltip.top="$t('SLA.FORM.DELETE')"
icon="i-woot-bin"
slate
sm
:is-loading="loading[sla.id]"
@click="openDeletePopup(sla)"
/>
</div>
</BaseTableCell>
</template>
</BaseTableRow>
</template>
</BaseTable>
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<AddSLA @close="hideAddPopup" />
@@ -1,5 +1,4 @@
<script setup>
import BaseEmptyState from './BaseEmptyState.vue';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const props = defineProps({
@@ -18,7 +17,7 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
</script>
<template>
<BaseEmptyState>
<div class="pb-6 pt-10 flex justify-center">
<BasePaywallModal
feature-prefix="SLA"
:i18n-key="i18nKey"
@@ -26,5 +25,5 @@ const i18nKey = props.isOnChatwootCloud ? 'PAYWALL' : 'ENTERPRISE_PAYWALL';
:is-super-admin="isSuperAdmin"
@upgrade="emit('upgrade')"
/>
</BaseEmptyState>
</div>
</template>
@@ -1,33 +0,0 @@
<script setup>
import SLAListItem from './SLAListItem.vue';
</script>
<template>
<div class="w-full min-h-[12rem] relative">
<div class="w-full space-y-3">
<SLAListItem
class="opacity-25 dark:opacity-20"
:sla-name="$t('SLA.LIST.EMPTY.TITLE_1')"
:description="$t('SLA.LIST.EMPTY.DESC_1')"
first-response="20m"
next-response="1h"
resolution-time="24h"
has-business-hours
/>
<SLAListItem
class="opacity-25 dark:opacity-20"
:sla-name="$t('SLA.LIST.EMPTY.TITLE_2')"
:description="$t('SLA.LIST.EMPTY.DESC_2')"
first-response="2h"
next-response="4h"
resolution-time="4d"
has-business-hours
/>
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center w-full h-full bg-gradient-to-t from-white dark:from-slate-900 to-transparent"
>
<slot />
</div>
</div>
</template>
@@ -1,32 +0,0 @@
<script setup>
defineProps({
hasBusinessHours: {
type: Boolean,
required: true,
},
});
</script>
<template>
<div
class="inline-flex items-center min-w-0 gap-1 px-1.5 sm:px-2 py-1 border border-solid rounded-lg border-n-weak"
>
<fluent-icon
size="14"
:icon="hasBusinessHours ? 'alarm-on' : 'alarm-off'"
type="outline"
class="flex-shrink-0"
:class="hasBusinessHours ? 'text-n-slate-11' : 'text-n-slate-10'"
/>
<span
class="hidden text-xs tracking-[0.2%] font-normal truncate sm:block"
:class="hasBusinessHours ? 'text-n-slate-11' : 'text-n-slate-10'"
>
{{
hasBusinessHours
? $t('SLA.LIST.BUSINESS_HOURS_ON')
: $t('SLA.LIST.BUSINESS_HOURS_OFF')
}}
</span>
</div>
</template>
@@ -1,21 +0,0 @@
<script setup>
import BaseEmptyState from './BaseEmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['primaryAction']);
const primaryAction = () => emit('primaryAction');
</script>
<template>
<BaseEmptyState>
<p class="max-w-xs text-sm font-medium text-center">
{{ $t('SLA.LIST.404') }}
</p>
<NextButton
icon="i-lucide-plus"
class="mt-4"
:label="$t('SLA.ADD_ACTION_LONG')"
@click="primaryAction"
/>
</BaseEmptyState>
</template>
@@ -1,30 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
defineProps({
showActions: {
type: Boolean,
default: true,
},
});
defineEmits(['add']);
</script>
<template>
<BaseSettingsHeader
:title="$t('SLA.HEADER')"
:description="$t('SLA.DESCRIPTION')"
:link-text="$t('SLA.LEARN_MORE')"
feature-name="sla"
>
<template v-if="showActions" #actions>
<Button
:label="$t('SLA.ADD_ACTION')"
icon="i-lucide-circle-plus"
@click="$emit('add')"
/>
</template>
</BaseSettingsHeader>
</template>

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