Merge branch 'develop' into feat/github-integration

This commit is contained in:
Muhsin Keloth
2025-08-07 09:47:27 +05:30
committed by GitHub
27 changed files with 464 additions and 450 deletions
@@ -38,11 +38,13 @@ const handleClose = () => emit('close');
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] rounded-xl border border-n-weak shadow-md max-h-[80vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-n-slate-12">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
<div class="p-6 flex flex-col gap-6">
<h3 class="text-base font-medium text-n-slate-12 flex-shrink-0">
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
</h3>
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</div>
</template>
@@ -1,9 +1,5 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -1,12 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import Thumbnail from '../Thumbnail.vue';
import MessagePreview from './MessagePreview.vue';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -14,257 +14,224 @@ import PriorityMark from './PriorityMark.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
export default {
components: {
CardLabels,
InboxName,
Thumbnail,
ConversationContextMenu,
TimeAgo,
MessagePreview,
PriorityMark,
SLACardLabel,
ContextMenu,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
default: '',
},
chat: {
type: Object,
default: () => {},
},
hideInboxName: {
type: Boolean,
default: false,
},
hideThumbnail: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
conversationType: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
allowedContextMenuOptions: {
type: Array,
default: () => [],
},
},
emits: [
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
hovered: false,
showContextMenu: false,
contextMenu: {
x: null,
y: null,
},
};
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
inboxesList: 'inboxes/getInboxes',
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
}),
chatMetadata() {
return this.chat.meta || {};
},
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
});
assignee() {
return this.chatMetadata.assignee || {};
},
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chatMetadata.sender.id
);
},
const router = useRouter();
isActiveChat() {
return this.currentChat.id === this.chat.id;
},
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
unreadCount() {
return this.chat.unread_count;
},
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactById = useMapGetter('contacts/getContact');
const inboxById = useMapGetter('inboxes/getInbox');
hasUnread() {
return this.unreadCount > 0;
},
const chatMetadata = computed(() => props.chat.meta || {});
isInboxNameVisible() {
return !this.activeInbox;
},
const assignee = computed(() => chatMetadata.value.assignee || {});
lastMessageInChat() {
return getLastMessage(this.chat);
},
const currentContact = computed(() => {
return contactById.value(chatMetadata.value.sender.id);
});
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
const isActiveChat = computed(() => {
return currentChat.value.id === props.chat.id;
});
showInboxName() {
return (
!this.hideInboxName &&
this.isInboxNameVisible &&
this.inboxesList.length > 1
);
},
inboxName() {
const stateInbox = this.inbox;
return stateInbox.name || '';
},
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
conversationPath() {
const { activeInbox, chat } = this;
return frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
},
},
methods: {
onCardClick(e) {
const path = this.conversationPath;
if (!path) return;
const unreadCount = computed(() => props.chat.unread_count);
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
const hasUnread = computed(() => unreadCount.value > 0);
// Skip if already active
if (this.isActiveChat) return;
const isInboxNameVisible = computed(() => !activeInbox.value);
router.push({ path });
},
onThumbnailHover() {
this.hovered = !this.hideThumbnail;
},
onThumbnailLeave() {
this.hovered = false;
},
onSelectConversation(checked) {
const action = checked ? 'selectConversation' : 'deSelectConversation';
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('contextMenuToggle', true);
this.contextMenu.x = e.pageX || e.clientX;
this.contextMenu.y = e.pageY || e.clientY;
this.showContextMenu = true;
},
closeContextMenu() {
this.$emit('contextMenuToggle', false);
this.showContextMenu = false;
this.contextMenu.x = null;
this.contextMenu.y = null;
},
onUpdateConversation(status, snoozedUntil) {
this.closeContextMenu();
this.$emit(
'updateConversationStatus',
this.chat.id,
status,
snoozedUntil
);
},
async onAssignAgent(agent) {
this.$emit('assignAgent', agent, [this.chat.id]);
this.closeContextMenu();
},
async onAssignLabel(label) {
this.$emit('assignLabel', [label.title], [this.chat.id]);
this.closeContextMenu();
},
async onAssignTeam(team) {
this.$emit('assignTeam', team, this.chat.id);
this.closeContextMenu();
},
async markAsUnread() {
this.$emit('markAsUnread', this.chat.id);
this.closeContextMenu();
},
async markAsRead() {
this.$emit('markAsRead', this.chat.id);
this.closeContextMenu();
},
async assignPriority(priority) {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const inbox = computed(() => {
const { inbox_id: inboxId } = props.chat;
const stateInbox = inboxById.value(inboxId);
return stateInbox;
});
const showInboxName = computed(() => {
return (
!props.hideInboxName &&
isInboxNameVisible.value &&
inboxesList.value.length > 1
);
});
const showMetaSection = computed(() => {
return (
showInboxName.value ||
(props.showAssignee && assignee.value.name) ||
props.chat.priority
);
});
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const showLabelsSection = computed(() => {
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
});
const messagePreviewClass = computed(() => {
return [
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
];
});
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
// Skip if already active
if (isActiveChat.value) return;
router.push({ path });
};
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
closeContextMenu();
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<div
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full px-3 py-0 border-t-0 border-b-0 border-l-2 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
isActiveChat,
'unread-chat': hasUnread,
'has-inbox-name': showInboxName,
'conversation-selected': selected,
'bg-n-slate-2 dark:bg-n-slate-3': selected,
'px-0': compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -276,13 +243,14 @@ export default {
>
<label
v-if="hovered || selected"
class="checkbox-wrapper absolute inset-0 z-20 backdrop-blur-[2px]"
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-20 backdrop-blur-[2px]"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
@click.stop
>
<input
:value="selected"
:checked="selected"
class="checkbox"
class="!m-0 cursor-pointer"
type="checkbox"
@change="onSelectConversation($event.target.checked)"
/>
@@ -290,22 +258,30 @@ export default {
<Thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
:class="!showInboxName ? 'mt-4' : 'mt-8'"
/>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 w-[calc(100%-40px)]"
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div class="flex items-center conversation-card--meta min-w-0">
<InboxName
v-if="showInboxName"
:inbox="inbox"
class="flex-1 min-w-0 mx-2"
/>
<div class="flex items-center gap-2 flex-shrink-0">
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
<div
class="flex items-center gap-2 flex-shrink-0"
:class="{
'flex-1 justify-between': !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
@@ -317,7 +293,7 @@ export default {
</div>
</div>
<h4
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap w-[calc(100%-70px)] text-n-slate-12"
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
:class="hasUnread ? 'font-semibold' : 'font-medium'"
>
{{ currentContact.name }}
@@ -325,24 +301,27 @@ export default {
<MessagePreview
v-if="lastMessageInChat"
:message="lastMessageInChat"
class="conversation--message my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] text-sm"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
:class="messagePreviewClass"
/>
<p
v-else
class="conversation--message text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 max-w-[96%] w-[16.875rem] overflow-hidden text-ellipsis whitespace-nowrap"
:class="hasUnread ? 'font-medium text-n-slate-12' : 'text-n-slate-11'"
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
:class="messagePreviewClass"
>
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-n-slate-10"
icon="info"
/>
<span>
<span class="mx-0.5">
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
</p>
<div class="absolute flex flex-col mt-4 ltr:right-4 rtl:left-4 top-4">
<div
class="absolute flex flex-col ltr:right-3 rtl:left-3"
:class="showMetaSection ? 'top-8' : 'top-4'"
>
<span class="ml-auto font-normal leading-4 text-xxs">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
@@ -350,12 +329,17 @@ export default {
/>
</span>
<span
class="unread shadow-lg rounded-full hidden text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
:class="hasUnread ? 'block' : 'hidden'"
>
{{ unreadCount > 9 ? '9+' : unreadCount }}
</span>
</div>
<CardLabels :conversation-labels="chat.labels" class="mt-0.5 mx-2 mb-0">
<CardLabels
v-if="showLabelsSection"
:conversation-labels="chat.labels"
class="mt-0.5 mx-2 mb-0"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
</template>
@@ -388,55 +372,3 @@ export default {
</ContextMenu>
</div>
</template>
<style lang="scss" scoped>
.conversation {
&.unread-chat {
.unread {
@apply block;
}
}
&.compact {
@apply pl-0;
.conversation-card--meta {
@apply ltr:pr-4 rtl:pl-4;
}
.conversation--details {
@apply rounded-sm ml-0 pl-5 pr-2;
}
}
&::v-deep .user-thumbnail-box {
@apply mt-4;
}
&.conversation-selected {
@apply bg-n-slate-2 dark:bg-n-slate-3;
}
&.has-inbox-name {
&::v-deep .user-thumbnail-box {
@apply mt-8;
}
.checkbox-wrapper {
@apply mt-8;
}
.conversation--meta {
@apply mt-4;
}
}
.checkbox-wrapper {
@apply flex items-center justify-center rounded-full cursor-pointer mt-4;
input[type='checkbox'] {
@apply m-0 cursor-pointer;
}
}
}
</style>
@@ -263,7 +263,7 @@ export default {
<style scoped lang="scss">
.bulk-action__container {
@apply p-4 relative border-b border-solid border-n-strong dark:border-n-weak;
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@@ -3,7 +3,7 @@ import WidgetHead from './WidgetHead.vue';
import WidgetBody from './WidgetBody.vue';
import WidgetFooter from './WidgetFooter.vue';
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
export default {
@@ -14,7 +14,6 @@ export default {
WidgetFooter,
InputRadioGroup,
},
mixins: [globalConfigMixin],
props: {
welcomeHeading: {
type: String,
@@ -57,6 +56,12 @@ export default {
default: '',
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
widgetScreens: [
@@ -159,9 +164,8 @@ export default {
/>
<span>
{{
useInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT'),
globalConfig.installationName
replaceInstallationName(
$t('INBOX_MGMT.WIDGET_BUILDER.BRANDING_TEXT')
)
}}
</span>
@@ -61,8 +61,8 @@ export default {
:hide-inbox-name="false"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
class="compact"
/>
</div>
</div>
@@ -75,12 +75,4 @@ export default {
.no-label-message {
@apply text-n-slate-11 mb-4;
}
::v-deep .conversation {
@apply pr-0;
.conversation--details {
@apply pl-2;
}
}
</style>
@@ -3,14 +3,19 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
export default {
components: {
ChannelItem,
PageHeader,
},
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
enabledFeatures: {},
@@ -69,12 +74,7 @@ export default {
<PageHeader
class="max-w-4xl"
:header-title="$t('INBOX_MGMT.ADD.AUTH.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.AUTH.DESC'),
globalConfig.installationName
)
"
:header-content="replaceInstallationName($t('INBOX_MGMT.ADD.AUTH.DESC'))"
/>
<div
class="grid max-w-3xl grid-cols-2 mx-0 mt-6 sm:grid-cols-3 lg:grid-cols-4"
@@ -1,9 +1,14 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
export default {
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -29,10 +34,7 @@ export default {
items() {
return this.createFlowSteps.map(item => ({
...item,
body: this.useInstallationName(
item.body,
this.globalConfig.installationName
),
body: this.replaceInstallationName(item.body),
}));
},
},
@@ -6,11 +6,11 @@ import { useAlert } from 'dashboard/composables';
import { useAccount } from 'dashboard/composables/useAccount';
import { required } from '@vuelidate/validators';
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
import { mapGetters } from 'vuex';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
@@ -22,11 +22,12 @@ export default {
PageHeader,
NextButton,
},
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
const { replaceInstallationName } = useBranding();
return {
accountId,
replaceInstallationName,
v$: useVuelidate(),
};
},
@@ -66,9 +67,6 @@ export default {
getSelectablePages() {
return this.pageList.filter(item => !item.exists);
},
...mapGetters({
globalConfig: 'globalConfig/get',
}),
},
mounted() {
@@ -223,12 +221,7 @@ export default {
/>
</a>
<p class="py-6">
{{
useInstallationName(
$t('INBOX_MGMT.ADD.FB.HELP'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('INBOX_MGMT.ADD.FB.HELP')) }}
</p>
</div>
<div v-else>
@@ -249,10 +242,7 @@ export default {
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.DETAILS.TITLE')"
:header-content="
useInstallationName(
$t('INBOX_MGMT.ADD.DETAILS.DESC'),
globalConfig.installationName
)
replaceInstallationName($t('INBOX_MGMT.ADD.DETAILS.DESC'))
"
/>
</div>
@@ -1,11 +1,9 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import instagramClient from 'dashboard/api/channel/instagramClient';
export default {
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -14,7 +13,6 @@ export default {
DashboardAppsRow,
NextButton,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -1,12 +1,14 @@
<script setup>
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { computed, onMounted } from 'vue';
import { useBranding } from 'shared/composables/useBranding';
import IntegrationItem from './IntegrationItem.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
const store = useStore();
const getters = useStoreGetters();
const { replaceInstallationName } = useBranding();
const uiFlags = getters['integrations/getUIFlags'];
@@ -27,7 +29,9 @@ onMounted(() => {
<template #header>
<BaseSettingsHeader
:title="$t('INTEGRATION_SETTINGS.HEADER')"
:description="$t('INTEGRATION_SETTINGS.DESCRIPTION')"
:description="
replaceInstallationName($t('INTEGRATION_SETTINGS.DESCRIPTION'))
"
:link-text="$t('INTEGRATION_SETTINGS.LEARN_MORE')"
feature-name="integrations"
/>
@@ -5,7 +5,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -26,11 +26,11 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const { replaceInstallationName } = useBranding();
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const globalConfig = computed(() => store.getters['globalConfig/get']);
const openDeletePopup = () => {
if (dialogRef.value) {
@@ -82,12 +82,7 @@ const confirmDeletion = () => {
{{ integrationName }}
</h3>
<p class="text-n-slate-11 text-sm leading-6">
{{
useInstallationName(
integrationDescription,
globalConfig.installationName
)
}}
{{ replaceInstallationName(integrationDescription) }}
</p>
</div>
</div>
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -28,9 +28,9 @@ const props = defineProps({
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const globalConfig = getters['globalConfig/get'];
const { t } = useI18n();
const { replaceInstallationName } = useBranding();
const integrationStatus = computed(() =>
props.enabled
@@ -80,7 +80,7 @@ const actionURL = computed(() =>
</router-link>
</div>
<p class="text-n-slate-11">
{{ useInstallationName(description, globalConfig.installationName) }}
{{ replaceInstallationName(description) }}
</p>
</div>
</div>
@@ -1,5 +1,4 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import IntegrationHelpText from './IntegrationHelpText.vue';
@@ -8,8 +7,6 @@ export default {
Integration,
IntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
@@ -3,7 +3,7 @@ import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -18,6 +18,7 @@ const store = useStore();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const { replaceInstallationName } = useBranding();
const selectedChannelId = ref('');
const availableChannels = ref([]);
@@ -29,16 +30,9 @@ const errorDescription = computed(() => {
? t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.DESCRIPTION')
: t('INTEGRATION_SETTINGS.SLACK.SELECT_CHANNEL.EXPIRED');
});
const globalConfig = computed(() => store.getters['globalConfig/get']);
const formattedErrorMessage = computed(() => {
return formatMessage(
useInstallationName(
errorDescription.value,
globalConfig.value.installationName
),
false
);
return formatMessage(replaceInstallationName(errorDescription.value), false);
});
const fetchChannels = async () => {
@@ -1,6 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import NextButton from 'dashboard/components-next/button/Button.vue';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
@@ -17,6 +18,10 @@ export default {
EditWebhook,
WebhookRow,
},
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
data() {
return {
loading: {},
@@ -99,7 +104,7 @@ export default {
<BaseSettingsHeader
v-if="integration.name"
:title="integration.name"
:description="integration.description"
:description="replaceInstallationName(integration.description)"
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
feature-name="webhook"
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
@@ -1,21 +1,25 @@
<script>
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'webhooks/getUIFlags',
}),
},
@@ -43,10 +47,7 @@ export default {
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
globalConfig.installationName
)
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
@@ -3,10 +3,10 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useBranding } from 'shared/composables/useBranding';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -37,16 +37,17 @@ export default {
AudioNotifications,
AccessToken,
},
mixins: [globalConfigMixin],
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
const { currentFontSize, updateFontSize } = useFontSize();
const { replaceInstallationName } = useBranding();
return {
currentFontSize,
updateFontSize,
isEditorHotKeyEnabled,
updateUISettings,
replaceInstallationName,
};
},
data() {
@@ -215,7 +216,11 @@ export default {
</div>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')"
:description="
replaceInstallationName(
$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.NOTE')
)
"
>
<FontSize
:value="currentFontSize"
@@ -288,10 +293,7 @@ export default {
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.TITLE')"
:description="
useInstallationName(
$t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'),
globalConfig.installationName
)
replaceInstallationName($t('PROFILE_SETTINGS.FORM.ACCESS_TOKEN.NOTE'))
"
>
<AccessToken
@@ -1,5 +1,5 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
const {
LOGO_THUMBNAIL: logoThumbnail,
@@ -8,13 +8,18 @@ const {
} = window.globalConfig || {};
export default {
mixins: [globalConfigMixin],
props: {
disableBranding: {
type: Boolean,
default: false,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
data() {
return {
globalConfig: {
@@ -61,7 +66,7 @@ export default {
:src="globalConfig.logoThumbnail"
/>
<span>
{{ useInstallationName($t('POWERED_BY'), globalConfig.brandName) }}
{{ replaceInstallationName($t('POWERED_BY')) }}
</span>
</a>
</div>
@@ -0,0 +1,96 @@
import { useBranding } from '../useBranding';
import { useMapGetter } from 'dashboard/composables/store.js';
// Mock the store composable
vi.mock('dashboard/composables/store.js', () => ({
useMapGetter: vi.fn(),
}));
describe('useBranding', () => {
let mockGlobalConfig;
beforeEach(() => {
mockGlobalConfig = {
value: {
installationName: 'MyCompany',
},
};
useMapGetter.mockReturnValue(mockGlobalConfig);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('replaceInstallationName', () => {
it('should replace "Chatwoot" with installation name when both text and installation name are provided', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to MyCompany');
});
it('should replace multiple occurrences of "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Chatwoot is great! Use Chatwoot today.'
);
expect(result).toBe('MyCompany is great! Use MyCompany today.');
});
it('should return original text when installation name is not provided', () => {
mockGlobalConfig.value = {};
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to Chatwoot');
});
it('should return original text when globalConfig is not available', () => {
mockGlobalConfig.value = undefined;
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to Chatwoot');
});
it('should return original text when text is empty or null', () => {
const { replaceInstallationName } = useBranding();
expect(replaceInstallationName('')).toBe('');
expect(replaceInstallationName(null)).toBe(null);
expect(replaceInstallationName(undefined)).toBe(undefined);
});
it('should handle text without "Chatwoot" gracefully', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to our platform');
expect(result).toBe('Welcome to our platform');
});
it('should be case-sensitive for "Chatwoot"', () => {
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName(
'Welcome to chatwoot and CHATWOOT'
);
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
});
it('should handle special characters in installation name', () => {
mockGlobalConfig.value = {
installationName: 'My-Company & Co.',
};
const { replaceInstallationName } = useBranding();
const result = replaceInstallationName('Welcome to Chatwoot');
expect(result).toBe('Welcome to My-Company & Co.');
});
});
});
@@ -0,0 +1,26 @@
/**
* Composable for branding-related utilities
* Provides methods to customize text with installation-specific branding
*/
import { useMapGetter } from 'dashboard/composables/store.js';
export function useBranding() {
const globalConfig = useMapGetter('globalConfig/get');
/**
* Replaces "Chatwoot" in text with the installation name from global config
* @param {string} text - The text to process
* @returns {string} - Text with "Chatwoot" replaced by installation name
*/
const replaceInstallationName = text => {
if (!text) return text;
const installationName = globalConfig.value?.installationName;
if (!installationName) return text;
return text.replace(/Chatwoot/g, installationName);
};
return {
replaceInstallationName,
};
}
@@ -1,12 +0,0 @@
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
export default {
methods: {
useInstallationName,
},
};
@@ -1,18 +1,17 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { required, minLength, email } from '@vuelidate/validators';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import FormInput from '../../../../components/Form/Input.vue';
import { resetPassword } from '../../../../api/auth';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { FormInput, NextButton },
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
const { replaceInstallationName } = useBranding();
return { v$: useVuelidate(), replaceInstallationName };
},
data() {
return {
@@ -24,9 +23,6 @@ export default {
error: '',
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
validations() {
return {
credentials: {
@@ -82,12 +78,7 @@ export default {
<p
class="mb-4 text-sm font-normal leading-6 tracking-normal text-n-slate-11"
>
{{
useInstallationName(
$t('RESET_PASSWORD.DESCRIPTION'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('RESET_PASSWORD.DESCRIPTION')) }}
</p>
<div class="space-y-5">
<FormInput
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -11,7 +11,10 @@ export default {
Spinner,
Testimonials,
},
mixins: [globalConfigMixin],
setup() {
const { replaceInstallationName } = useBranding();
return { replaceInstallationName };
},
data() {
return { isLoading: false };
},
@@ -61,12 +64,7 @@ export default {
<div class="px-1 text-sm text-n-slate-12">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
<router-link class="text-link text-n-brand" to="/app/login">
{{
useInstallationName(
$t('LOGIN.TITLE'),
globalConfig.installationName
)
}}
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
</router-link>
</div>
</div>
@@ -3,7 +3,6 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
@@ -20,7 +19,6 @@ export default {
NextButton,
VueHcaptcha,
},
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
+7 -7
View File
@@ -8,8 +8,7 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
import SessionStorage from 'shared/helpers/sessionStorage';
// mixins
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useBranding } from 'shared/composables/useBranding';
// components
import FormInput from '../../components/Form/Input.vue';
@@ -31,7 +30,6 @@ export default {
Spinner,
NextButton,
},
mixins: [globalConfigMixin],
props: {
ssoAuthToken: { type: String, default: '' },
ssoAccountId: { type: String, default: '' },
@@ -40,7 +38,11 @@ export default {
authError: { type: String, default: '' },
},
setup() {
return { v$: useVuelidate() };
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
v$: useVuelidate(),
};
},
data() {
return {
@@ -182,9 +184,7 @@ export default {
class="hidden w-auto h-8 mx-auto dark:block"
/>
<h2 class="mt-6 text-3xl font-medium text-center text-n-slate-12">
{{
useInstallationName($t('LOGIN.TITLE'), globalConfig.installationName)
}}
{{ replaceInstallationName($t('LOGIN.TITLE')) }}
</h2>
<p v-if="showSignupLink" class="mt-3 text-sm text-center text-n-slate-11">
{{ $t('COMMON.OR') }}