Merge branch 'develop' into fix/auth-methods

This commit is contained in:
Shivam Mishra
2025-05-26 16:58:19 +05:30
committed by GitHub
1265 changed files with 67285 additions and 10739 deletions
@@ -0,0 +1,71 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import EditAssistantForm from '../../../../components-next/captain/pageComponents/assistant/EditAssistantForm.vue';
import AssistantPlayground from 'dashboard/components-next/captain/assistant/AssistantPlayground.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const assistantId = route.params.assistantId;
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const assistant = computed(() =>
store.getters['captainAssistants/getRecord'](Number(assistantId))
);
const isAssistantAvailable = computed(() => !!assistant.value?.id);
const handleSubmit = async updatedAssistant => {
try {
await store.dispatch('captainAssistants/update', {
id: assistantId,
...updatedAssistant,
});
useAlert(t('CAPTAIN.ASSISTANTS.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.message || t('CAPTAIN.ASSISTANTS.EDIT.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
onMounted(() => {
if (!isAssistantAvailable.value) {
store.dispatch('captainAssistants/show', assistantId);
}
});
</script>
<template>
<PageLayout
:header-title="assistant?.name"
:show-pagination-footer="false"
:is-fetching="isFetching"
:show-know-more="false"
:back-url="{ name: 'captain_assistants_index' }"
>
<template #body>
<div v-if="!isAssistantAvailable">
{{ t('CAPTAIN.ASSISTANTS.EDIT.NOT_FOUND') }}
</div>
<div v-else class="flex gap-4 h-full">
<div class="flex-1 lg:overflow-auto pr-4 h-full md:h-auto">
<EditAssistantForm
:assistant="assistant"
mode="edit"
@submit="handleSubmit"
/>
</div>
<div class="w-[400px] hidden lg:block h-full">
<AssistantPlayground :assistant-id="Number(assistantId)" />
</div>
</div>
</template>
</PageLayout>
</template>
@@ -36,8 +36,10 @@ const handleCreate = () => {
};
const handleEdit = () => {
dialogType.value = 'edit';
nextTick(() => createAssistantDialog.value.dialogRef.open());
router.push({
name: 'captain_assistants_edit',
params: { assistantId: selectedAssistant.value.id },
});
};
const handleViewConnectedInboxes = () => {
@@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue';
import AssistantEdit from './assistants/Edit.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
@@ -20,6 +21,19 @@ export const routes = [
],
},
},
{
path: frontendURL('accounts/:accountId/captain/assistants/:assistantId'),
component: AssistantEdit,
name: 'captain_assistants_edit',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/inboxes'
@@ -13,6 +13,7 @@ import ConversationAction from './ConversationAction.vue';
import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue';
import ContactNotes from './contact/ContactNotes.vue';
import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
@@ -245,6 +246,18 @@ onMounted(() => {
<ShopifyOrdersList :contact-id="contactId" />
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_notes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
:is-open="isContactSidebarItemOpen('is_contact_notes_open')"
compact
@toggle="
value => toggleSidebarUIState('is_contact_notes_open', value)
"
>
<ContactNotes :contact-id="contactId" />
</AccordionItem>
</div>
</div>
</template>
</Draggable>
@@ -0,0 +1,51 @@
<script setup>
import { watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
import Spinner from 'next/spinner/Spinner.vue';
const { contactId } = defineProps({
contactId: { type: String, required: true },
});
const { t } = useI18n();
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
const notes = computed(() => notGetterFn.value(contactId));
const getWrittenBy = ({ user } = {}) => {
const currentUserId = currentUser.value?.id;
return user?.id === currentUserId
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
: user?.name || t('CONVERSATION.BOT');
};
watch(
() => contactId,
() => store.dispatch('contactNotes/get', { contactId }),
{ immediate: true }
);
</script>
<template>
<div v-if="isFetchingNotes" class="p-8 grid place-content-center">
<Spinner />
</div>
<div v-else-if="!notes.length" class="p-8 grid place-content-center">
<p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
</div>
<div v-else class="max-h-[300px] overflow-scroll">
<ContactNoteItem
v-for="note in notes"
:key="note.id"
class="p-4 last-of-type:border-b-0"
:note="note"
collapsible
:written-by="getWrittenBy(note)"
/>
</div>
</template>
@@ -80,17 +80,13 @@ const filteredCustomAttributes = computed(() =>
customAttributes.value,
attribute.attribute_key
);
const isCheckbox = attribute.attribute_display_type === 'checkbox';
const defaultValue = isCheckbox ? false : '';
return {
...attribute,
type: 'custom_attribute',
key: attribute.attribute_key,
// Set value from customAttributes if it exists, otherwise use default value
value: hasValue
? customAttributes.value[attribute.attribute_key]
: defaultValue,
// Set value from customAttributes if it exists, otherwise use ''
value: hasValue ? customAttributes.value[attribute.attribute_key] : '',
};
})
);
@@ -215,7 +211,7 @@ const onUpdate = async (key, value) => {
} else {
store.dispatch('contacts/update', {
id: props.contactId,
custom_attributes: updatedAttributes,
customAttributes: updatedAttributes,
});
}
useAlert(t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
@@ -1,25 +1,34 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minValue, maxValue } from '@vuelidate/validators';
import { required } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
components: {
BaseSettingsHeader,
V4Button,
WootConfirmDeleteModal,
NextButton,
AccountId,
BuildInfo,
AccountDelete,
AutoResolve,
SectionLayout,
WithLabel,
NextInput,
},
setup() {
const { updateUISettings } = useUISettings();
@@ -37,9 +46,6 @@ export default {
domain: '',
supportEmail: '',
features: {},
autoResolveDuration: null,
latestChatwootVersion: null,
showDeletePopup: false,
};
},
validations: {
@@ -49,14 +55,9 @@ export default {
locale: {
required,
},
autoResolveDuration: {
minValue: minValue(1),
maxValue: maxValue(999),
},
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
@@ -68,16 +69,6 @@ export default {
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
},
hasAnUpdateAvailable() {
if (!semver.valid(this.latestChatwootVersion)) {
return false;
}
return semver.lt(
this.globalConfig.appVersion,
this.latestChatwootVersion
);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
@@ -87,51 +78,19 @@ export default {
isUpdating() {
return this.uiFlags.isUpdating;
},
featureInboundEmailEnabled() {
return !!this.features?.inbound_emails;
},
featureCustomReplyDomainEnabled() {
return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
);
},
featureCustomReplyEmailEnabled() {
return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_email
);
},
getAccountId() {
return this.id.toString();
},
confirmPlaceHolderText() {
return `${this.$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER',
{
accountName: this.name,
}
)}`;
},
isMarkedForDeletion() {
const { custom_attributes = {} } = this.currentAccount;
return !!custom_attributes.marked_for_deletion_at;
},
markedForDeletionDate() {
const { custom_attributes = {} } = this.currentAccount;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
},
markedForDeletionReason() {
const { custom_attributes = {} } = this.currentAccount;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
},
formattedDeletionDate() {
if (!this.markedForDeletionDate) return '';
return this.markedForDeletionDate.toLocaleString();
},
currentAccount() {
return this.getAccount(this.accountId) || {};
},
@@ -142,16 +101,8 @@ export default {
methods: {
async initializeAccount() {
try {
const {
name,
locale,
id,
domain,
support_email,
features,
auto_resolve_duration,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(this.accountId);
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
this.$root.$i18n.locale = locale;
this.name = name;
@@ -160,8 +111,6 @@ export default {
this.domain = domain;
this.supportEmail = support_email;
this.features = features;
this.autoResolveDuration = auto_resolve_duration;
this.latestChatwootVersion = latestChatwootVersion;
} catch (error) {
// Ignore error
}
@@ -179,7 +128,6 @@ export default {
name: this.name,
domain: this.domain,
support_email: this.supportEmail,
auto_resolve_duration: this.autoResolveDuration,
});
this.$root.$i18n.locale = this.locale;
this.getAccount(this.id).locale = this.locale;
@@ -196,252 +144,101 @@ export default {
rtl_view: isRTLSupported,
});
},
// Delete Function
openDeletePopup() {
this.showDeletePopup = true;
},
closeDeletePopup() {
this.showDeletePopup = false;
},
async markAccountForDeletion() {
this.closeDeletePopup();
try {
// Use the enterprise API to toggle deletion with delete action
await this.$store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await this.$store.dispatch('accounts/get');
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
this.handleDeletionError(error);
}
},
handleDeletionError(error) {
const errorKey = error.response?.data?.error_key;
if (errorKey) {
useAlert(
this.$t(`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.${errorKey}`)
);
return;
}
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
},
async clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await this.$store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await this.$store.dispatch('accounts/get');
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
},
};
</script>
<template>
<div class="flex flex-col w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')">
<template #actions>
<V4Button blue :loading="isUpdating" @click="updateAccount">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
<div class="flex flex-col max-w-2xl mx-auto w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<div class="flex-grow flex-shrink min-w-0 mt-3">
<SectionLayout
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
>
<form
v-if="!uiFlags.isFetchingItem"
class="grid gap-4"
@submit.prevent="updateAccount"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
<WithLabel
:has-error="v$.name.$error"
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
<NextInput
v-model="name"
type="text"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
</WithLabel>
<WithLabel
:has-error="v$.locale.$error"
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
>
<select v-model="locale" class="!mb-0 text-sm">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
</WithLabel>
<WithLabel
v-if="featureCustomReplyDomainEnabled"
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
>
<NextInput
v-model="domain"
type="text"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
<template #help>
{{
featureInboundEmailEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED')
}}
{{
featureCustomReplyDomainEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
</template>
</WithLabel>
<WithLabel
v-if="featureCustomReplyEmailEnabled"
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
>
<NextInput
v-model="supportEmail"
type="text"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</WithLabel>
<div>
<NextButton blue :is-loading="isUpdating" type="submit">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</NextButton>
</div>
</div>
</form>
</form>
</SectionLayout>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<div class="flex flex-row">
<div class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0">
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<woot-code :script="getAccountId" />
</div>
</div>
<AutoResolve v-if="showAutoResolutionConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<div
class="flex flex-row pt-4 mt-2 border-t border-slate-25 dark:border-slate-800 text-black-900 dark:text-slate-300"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<div v-if="isMarkedForDeletion">
<div
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
>
<p class="mb-4">
{{
$t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`,
{
deletionDate: formattedDeletionDate,
}
)
}}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="openDeletePopup()"
/>
</div>
</div>
</div>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')
"
:confirm-value="name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="closeDeletePopup"
/>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="build-id">
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
<AccountDelete />
</div>
<BuildInfo />
</div>
</template>
@@ -0,0 +1,149 @@
<script setup>
import { computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('accounts/getUIFlags');
const { currentAccount } = useAccount();
const [showDeletePopup, toggleDeletePopup] = useToggle();
const confirmPlaceHolderText = computed(() => {
return `${t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER', {
accountName: currentAccount.value.name,
})}`;
});
const isMarkedForDeletion = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return !!custom_attributes.marked_for_deletion_at;
});
const markedForDeletionDate = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
});
const markedForDeletionReason = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
});
const formattedDeletionDate = computed(() => {
if (!markedForDeletionDate.value) return '';
return markedForDeletionDate.value.toLocaleString();
});
const markedForDeletionMessage = computed(() => {
const params = { deletionDate: formattedDeletionDate.value };
if (markedForDeletionReason.value === 'manual_deletion') {
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_MANUAL`,
params
);
}
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_INACTIVITY`,
params
);
});
function handleDeletionError(error) {
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
}
async function markAccountForDeletion() {
toggleDeletePopup(false);
try {
// Use the enterprise API to toggle deletion with delete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
handleDeletionError(error);
}
}
async function clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
}
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE')"
:description="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE')"
with-border
>
<div v-if="isMarkedForDeletion">
<div
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
>
<p class="mb-4">
{{ markedForDeletionMessage }}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="toggleDeletePopup(true)"
/>
</div>
</SectionLayout>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')"
:confirm-value="currentAccount.name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="toggleDeletePopup(false)"
/>
</template>
@@ -0,0 +1,22 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE')"
with-border
>
<woot-code :script="getAccountId" />
</SectionLayout>
</template>
@@ -0,0 +1,141 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const duration = ref(0);
const message = ref('');
const ignoreWaiting = ref(false);
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const {
auto_resolve_after,
auto_resolve_message,
auto_resolve_ignore_waiting,
} = currentAccount.value?.settings || {};
duration.value = auto_resolve_after;
message.value = auto_resolve_message;
ignoreWaiting.value = auto_resolve_ignore_waiting;
if (duration.value) {
isEnabled.value = true;
}
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
}
};
const handleSubmit = async () => {
if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
return Promise.resolve();
}
return updateAccountSettings({
auto_resolve_after: duration.value,
auto_resolve_message: message.value,
auto_resolve_ignore_waiting: ignoreWaiting.value,
});
};
const handleDisable = async () => {
duration.value = null;
message.value = '';
return updateAccountSettings({
auto_resolve_after: null,
auto_resolve_message: '',
auto_resolve_ignore_waiting: false,
});
};
const toggleAutoResolve = async () => {
if (!isEnabled.value) handleDisable();
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</template>
<form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
>
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
min="0"
max="1439856"
class="w-full"
/>
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
:help-message="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
>
<TextArea
v-model="message"
class="w-full"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
"
/>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
>
<template #rightOfLabel>
<Switch v-model="ignoreWaiting" />
</template>
<p class="text-sm ml-px text-n-slate-10 max-w-lg">
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
</p>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:label="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
"
/>
</div>
</form>
</SectionLayout>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useI18n } from 'vue-i18n';
import semver from 'semver';
const { t } = useI18n();
const { currentAccount } = useAccount();
const latestChatwootVersion = computed(() => {
return currentAccount.value.latest_chatwoot_version;
});
const globalConfig = useMapGetter('globalConfig/get');
const hasAnUpdateAvailable = computed(() => {
if (!semver.valid(latestChatwootVersion.value)) {
return false;
}
return semver.lt(globalConfig.value.appVersion, latestChatwootVersion.value);
});
const gitSha = computed(() => {
return globalConfig.value.gitSha.substring(0, 7);
});
const copyGitSha = () => {
copyTextToClipboard(globalConfig.value.gitSha);
};
</script>
<template>
<div class="p-4 text-sm text-center">
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="divide-x divide-n-slate-9">
<span class="px-2">{{ `v${globalConfig.appVersion}` }}</span>
<span
v-tooltip="t('COMPONENTS.CODE.BUTTON_TEXT')"
class="px-2 build-id cursor-pointer"
@click="copyGitSha"
>
{{ `Build ${gitSha}` }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
});
</script>
<template>
<section
class="grid grid-cols-1 py-8 gap-8"
:class="{ 'border-t border-n-weak': withBorder }"
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12">
<slot name="title">{{ title }}</slot>
</h4>
<p class="text-n-slate-11 text-sm mt-2">
<slot name="description">{{ description }}</slot>
</p>
</div>
<div class="col-span-1">
<slot name="headerActions" />
</div>
</header>
<div class="text-n-slate-12">
<slot />
</div>
</section>
</template>
@@ -5,12 +5,15 @@ import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { required, helpers, url } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useToggle } from '@vueuse/core';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
const props = defineProps({
type: {
@@ -42,6 +45,9 @@ const formState = reactive({
botAvatarUrl: '',
});
const [showAccessToken, toggleAccessToken] = useToggle();
const accessToken = ref('');
const v$ = useVuelidate(
{
botName: {
@@ -70,11 +76,22 @@ const isLoading = computed(() =>
: uiFlags.value.isUpdating
);
const dialogTitle = computed(() =>
props.type === MODAL_TYPES.CREATE
const dialogTitle = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.TITLE');
}
return props.type === MODAL_TYPES.CREATE
? t('AGENT_BOTS.ADD.TITLE')
: t('AGENT_BOTS.EDIT.TITLE')
);
: t('AGENT_BOTS.EDIT.TITLE');
});
const dialogDescription = computed(() => {
if (showAccessToken.value) {
return t('AGENT_BOTS.ACCESS_TOKEN.DESCRIPTION');
}
return '';
});
const confirmButtonLabel = computed(() =>
props.type === MODAL_TYPES.CREATE
@@ -90,6 +107,13 @@ const botUrlError = computed(() =>
v$.value.botUrl.$error ? v$.value.botUrl.$errors[0]?.$message : ''
);
const showAccessTokenInput = computed(
() =>
showAccessToken.value ||
props.type === MODAL_TYPES.EDIT ||
accessToken.value
);
const resetForm = () => {
Object.assign(formState, {
botName: '',
@@ -128,6 +152,7 @@ const handleAvatarDelete = async () => {
const handleSubmit = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
if (showAccessToken.value) return;
const botData = {
name: formState.botName,
@@ -144,7 +169,7 @@ const handleSubmit = async () => {
? botData
: { id: props.selectedBot.id, data: botData };
await store.dispatch(
const response = await store.dispatch(
`agentBots/${isCreate ? 'create' : 'update'}`,
actionPayload
);
@@ -154,7 +179,21 @@ const handleSubmit = async () => {
: t('AGENT_BOTS.EDIT.API.SUCCESS_MESSAGE');
useAlert(alertKey);
dialogRef.value.close();
// Show access token after creation
if (isCreate) {
const { access_token: responseAccessToken, id } = response || {};
if (id && responseAccessToken) {
accessToken.value = responseAccessToken;
toggleAccessToken(true);
} else {
accessToken.value = '';
dialogRef.value.close();
}
} else {
dialogRef.value.close();
}
resetForm();
} catch (error) {
const errorKey = isCreate
@@ -166,17 +205,43 @@ const handleSubmit = async () => {
const initializeForm = () => {
if (props.selectedBot && Object.keys(props.selectedBot).length) {
const { name, description, outgoing_url, thumbnail, bot_config } =
props.selectedBot;
const {
name,
description,
outgoing_url: botUrl,
thumbnail,
bot_config: botConfig,
access_token: botAccessToken,
} = props.selectedBot;
formState.botName = name || '';
formState.botDescription = description || '';
formState.botUrl = outgoing_url || bot_config?.webhook_url || '';
formState.botUrl = botUrl || botConfig?.webhook_url || '';
formState.botAvatarUrl = thumbnail || '';
if (botAccessToken && props.type === MODAL_TYPES.EDIT) {
accessToken.value = botAccessToken;
}
} else {
resetForm();
}
};
const onCopyToken = async value => {
await copyTextToClipboard(value);
useAlert(t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
};
const closeModal = () => {
if (!showAccessToken.value) v$.value?.$reset();
accessToken.value = '';
toggleAccessToken(false);
};
const onClickClose = () => {
closeModal();
dialogRef.value.close();
};
watch(() => props.selectedBot, initializeForm, { immediate: true, deep: true });
defineExpose({ dialogRef });
@@ -187,48 +252,68 @@ defineExpose({ dialogRef });
ref="dialogRef"
type="edit"
:title="dialogTitle"
:description="dialogDescription"
:show-cancel-button="false"
:show-confirm-button="false"
@close="v$.$reset()"
@close="closeModal"
>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
@upload="handleImageUpload"
@delete="handleAvatarDelete"
<div
v-if="!showAccessToken || type === MODAL_TYPES.EDIT"
class="flex flex-col gap-4"
>
<div class="mb-2 flex flex-col items-start">
<span class="mb-2 text-sm font-medium text-n-slate-12">
{{ $t('AGENT_BOTS.FORM.AVATAR.LABEL') }}
</span>
<Avatar
:src="formState.botAvatarUrl"
:name="formState.botName"
:size="68"
allow-upload
icon-name="i-lucide-bot-message-square"
@upload="handleImageUpload"
@delete="handleAvatarDelete"
/>
</div>
<Input
id="bot-name"
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
id="bot-description"
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
id="bot-url"
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
</div>
<Input
v-model="formState.botName"
:label="$t('AGENT_BOTS.FORM.NAME.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.NAME.PLACEHOLDER')"
:message="botNameError"
:message-type="botNameError ? 'error' : 'info'"
@blur="v$.botName.$touch()"
/>
<TextArea
v-model="formState.botDescription"
:label="$t('AGENT_BOTS.FORM.DESCRIPTION.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<Input
v-model="formState.botUrl"
:label="$t('AGENT_BOTS.FORM.WEBHOOK_URL.LABEL')"
:placeholder="$t('AGENT_BOTS.FORM.WEBHOOK_URL.PLACEHOLDER')"
:message="botUrlError"
:message-type="botUrlError ? 'error' : 'info'"
@blur="v$.botUrl.$touch()"
/>
<div v-if="showAccessTokenInput" class="flex flex-col gap-1">
<label
v-if="type === MODAL_TYPES.EDIT"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ $t('AGENT_BOTS.ACCESS_TOKEN.TITLE') }}
</label>
<AccessToken :value="accessToken" @on-copy="onCopyToken" />
</div>
<div class="flex items-center justify-end w-full gap-2 px-0 py-2">
<NextButton
@@ -236,9 +321,10 @@ defineExpose({ dialogRef });
slate
type="reset"
:label="$t('AGENT_BOTS.FORM.CANCEL')"
@click="dialogRef.close()"
@click="onClickClose()"
/>
<NextButton
v-if="!showAccessToken"
type="submit"
data-testid="label-submit"
:label="confirmButtonLabel"
@@ -15,6 +15,7 @@ import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import CustomerSatisfactionPage from './settingsPage/CustomerSatisfactionPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
@@ -28,6 +29,7 @@ export default {
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
CustomerSatisfactionPage,
FacebookReauthorize,
GreetingsEditor,
PreChatFormSettings,
@@ -53,7 +55,6 @@ export default {
greetingEnabled: true,
greetingMessage: '',
emailCollectEnabled: false,
csatSurveyEnabled: false,
senderNameType: 'friendly',
businessName: '',
locktoSingleConversation: false,
@@ -107,6 +108,10 @@ export default {
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'csat',
name: this.$t('INBOX_MGMT.TABS.CSAT'),
},
];
if (this.isAWebWidgetInbox) {
@@ -277,7 +282,6 @@ export default {
this.greetingEnabled = this.inbox.greeting_enabled || false;
this.greetingMessage = this.inbox.greeting_message || '';
this.emailCollectEnabled = this.inbox.enable_email_collect;
this.csatSurveyEnabled = this.inbox.csat_survey_enabled;
this.senderNameType = this.inbox.sender_name_type;
this.businessName = this.inbox.business_name;
this.allowMessagesAfterResolved =
@@ -300,7 +304,6 @@ export default {
id: this.currentInboxId,
name: this.selectedInboxName,
enable_email_collect: this.emailCollectEnabled,
csat_survey_enabled: this.csatSurveyEnabled,
allow_messages_after_resolved: this.allowMessagesAfterResolved,
greeting_enabled: this.greetingEnabled,
greeting_message: this.greetingMessage || '',
@@ -589,21 +592,6 @@ export default {
</p>
</label>
<label class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT') }}
<select v-model="csatSurveyEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.ENABLE_CSAT.DISABLED') }}
</option>
</select>
<p class="pb-1 text-sm not-italic text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CSAT_SUB_TEXT') }}
</p>
</label>
<label v-if="isAWebWidgetInbox" class="pb-4">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MESSAGES_AFTER_RESOLVED') }}
<select v-model="allowMessagesAfterResolved">
@@ -802,6 +790,9 @@ export default {
<div v-if="selectedTabKey === 'configuration'">
<ConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
<CustomerSatisfactionPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'preChatForm'">
<PreChatFormSettings :inbox="inbox" />
</div>
@@ -0,0 +1,233 @@
<script setup>
import { reactive, onMounted, ref, defineProps, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SectionLayout from 'dashboard/routes/dashboard/settings/account/components/SectionLayout.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.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';
const props = defineProps({
inbox: { type: Object, required: true },
});
const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const isUpdating = ref(false);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
const state = reactive({
csatSurveyEnabled: false,
displayType: 'emoji',
message: '',
surveyRuleOperator: 'contains',
});
const filterTypes = [
{
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.CONTAINS'),
value: 'contains',
},
{
label: t('INBOX_MGMT.CSAT.SURVEY_RULE.OPERATOR.DOES_NOT_CONTAINS'),
value: 'does_not_contain',
},
];
const labelOptions = computed(() =>
labels.value?.length
? labels.value
.map(label => ({ label: label.title, value: label.title }))
.filter(label => !selectedLabelValues.value.includes(label.value))
: []
);
const initializeState = () => {
if (!props.inbox) return;
const { csat_survey_enabled, csat_config } = props.inbox;
state.csatSurveyEnabled = csat_survey_enabled || false;
if (!csat_config) return;
const {
display_type: displayType = CSAT_DISPLAY_TYPES.EMOJI,
message = '',
survey_rules: surveyRules = {},
} = csat_config;
state.displayType = displayType;
state.message = message;
state.surveyRuleOperator = surveyRules.operator || 'contains';
selectedLabelValues.value = Array.isArray(surveyRules.values)
? [...surveyRules.values]
: [];
};
onMounted(() => {
initializeState();
if (!labels.value?.length) store.dispatch('labels/get');
});
watch(() => props.inbox, initializeState, { immediate: true });
const handleLabelSelect = value => {
if (!value || selectedLabelValues.value.includes(value)) {
return;
}
selectedLabelValues.value.push(value);
};
const updateDisplayType = type => {
state.displayType = type;
};
const updateSurveyRuleOperator = operator => {
state.surveyRuleOperator = operator;
};
const removeLabel = label => {
const index = selectedLabelValues.value.indexOf(label);
if (index !== -1) {
selectedLabelValues.value.splice(index, 1);
}
};
const updateInbox = async attributes => {
const payload = {
id: props.inbox.id,
formData: false,
...attributes,
};
return store.dispatch('inboxes/updateInbox', payload);
};
const saveSettings = async () => {
try {
isUpdating.value = true;
const csatConfig = {
display_type: state.displayType,
message: state.message,
survey_rules: {
operator: state.surveyRuleOperator,
values: selectedLabelValues.value,
},
};
await updateInbox({
csat_survey_enabled: state.csatSurveyEnabled,
csat_config: csatConfig,
});
useAlert(t('INBOX_MGMT.CSAT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.CSAT.API.ERROR_MESSAGE'));
} finally {
isUpdating.value = false;
}
};
</script>
<template>
<div class="mx-8">
<SectionLayout
:title="$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">
<WithLabel
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
name="display_type"
>
<CSATDisplayTypeSelector
:selected-type="state.displayType"
@update="updateDisplayType"
/>
</WithLabel>
<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"
/>
</WithLabel>
<WithLabel
:label="$t('INBOX_MGMT.CSAT.SURVEY_RULE.LABEL')"
name="survey_rule"
>
<div class="mb-4">
<span
class="inline-flex flex-wrap items-center gap-1.5 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">
{{ $t('INBOX_MGMT.CSAT.NOTE') }}
</p>
<div>
<NextButton
type="submit"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdating"
@click="saveSettings"
/>
</div>
</div>
</SectionLayout>
</div>
</template>
@@ -0,0 +1,26 @@
<script setup>
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import CSATEmojiInput from './CSATEmojiInput.vue';
import CSATStarInput from './CSATStarInput.vue';
const props = defineProps({
selectedType: {
type: String,
default: CSAT_DISPLAY_TYPES.EMOJI,
},
});
const emit = defineEmits(['update']);
</script>
<template>
<div class="flex flex-wrap gap-6 mt-2">
<CSATEmojiInput
:selected="props.selectedType === CSAT_DISPLAY_TYPES.EMOJI"
@update="emit('update', $event)"
/>
<CSATStarInput
:selected="props.selectedType === CSAT_DISPLAY_TYPES.STAR"
@update="emit('update', $event)"
/>
</div>
</template>
@@ -0,0 +1,43 @@
<script setup>
import { ref, computed } from 'vue';
import { CSAT_RATINGS, CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
const props = defineProps({
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const selectionClass = computed(() => {
return props.selected
? 'outline-n-brand bg-n-brand/5'
: 'outline-n-weak bg-n-alpha-black2';
});
const emojis = CSAT_RATINGS;
const selectedEmoji = ref(5);
</script>
<template>
<button
class="flex items-center rounded-lg transition-all duration-500 cursor-pointer outline outline-1 px-4 py-2 gap-2 min-w-56"
:class="selectionClass"
@click="emit('update', CSAT_DISPLAY_TYPES.EMOJI)"
>
<div
v-for="emoji in emojis"
:key="emoji.key"
class="rounded-full p-1 transition-transform duration-150 focus:outline-none flex items-center flex-shrink-0"
>
<span
class="text-2xl"
:class="selectedEmoji === emoji.value ? '' : 'grayscale opacity-60'"
>
{{ emoji.emoji }}
</span>
</div>
</button>
</template>
@@ -0,0 +1,36 @@
<script setup>
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import { computed } from 'vue';
const props = defineProps({
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
const selectionClass = computed(() => {
return props.selected
? 'bg-n-brand/5 outline-n-brand'
: 'bg-n-alpha-black2 outline-n-weak';
});
</script>
<template>
<button
class="flex items-center rounded-lg transition-all duration-300 cursor-pointer outline outline-1 px-4 py-2 gap-2 min-w-56"
:class="selectionClass"
@click="emit('update', CSAT_DISPLAY_TYPES.STAR)"
>
<div
v-for="n in 5"
:key="'star-' + n"
class="rounded-full p-1 transition-transform duration-150 focus:outline-none flex items-center flex-shrink-0"
:aria-label="`Star ${n}`"
>
<i class="i-ri-star-fill text-n-amber-9 text-2xl" />
</div>
</button>
</template>
@@ -80,7 +80,7 @@ export default {
}, {});
this.formItems.forEach(item => {
if (item.validation.includes('JSON')) {
if (item.validation?.includes('JSON')) {
hookPayload.settings[item.name] = JSON.parse(
hookPayload.settings[item.name]
);
@@ -117,7 +117,7 @@ export default {
<div class="flex flex-col h-auto overflow-auto integration-hooks">
<woot-modal-header
:header-title="integration.name"
:header-content="integration.description"
:header-content="integration.short_description"
/>
<FormKit
v-model="values"
@@ -169,6 +169,10 @@ export default {
@apply hidden;
}
.formkit-form .formkit-help {
@apply text-n-slate-10 text-sm font-normal mt-2 w-full;
}
/* equivalent of .reset-base */
.formkit-input {
margin-bottom: 0px !important;
@@ -16,6 +16,8 @@ const SUPPORTED_WEBHOOK_EVENTS = [
'webwidget_triggered',
'contact_created',
'contact_updated',
'conversation_typing_on',
'conversation_typing_off',
];
export default {
@@ -39,6 +39,7 @@ const onClick = () => {
<template #masked>
<button
class="absolute top-1.5 ltr:right-0.5 rtl:left-0.5"
type="button"
@click="toggleMasked"
>
<fluent-icon :icon="maskIcon" :size="16" />
@@ -46,7 +47,7 @@ const onClick = () => {
</template>
</woot-input>
<FormButton
type="submit"
type="button"
size="large"
icon="text-copy"
variant="outline"