Merge branch 'develop' into feat/v5-ui-redesign

This commit is contained in:
Sivin Varghese
2025-12-17 22:54:42 +05:30
committed by GitHub
94 changed files with 2759 additions and 136 deletions
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class TiktokChannel extends ApiClient {
constructor() {
super('tiktok', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new TiktokChannel();
@@ -0,0 +1,35 @@
import ApiClient from '../ApiClient';
import tiktokClient from '../channel/tiktokClient';
describe('#TiktokClient', () => {
it('creates correct instance', () => {
expect(tiktokClient).toBeInstanceOf(ApiClient);
expect(tiktokClient).toHaveProperty('generateAuthorization');
});
describe('#generateAuthorization', () => {
const originalAxios = window.axios;
const originalPathname = window.location.pathname;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
window.history.pushState({}, '', '/app/accounts/1/settings');
});
afterEach(() => {
window.axios = originalAxios;
window.history.pushState({}, '', originalPathname);
});
it('posts to the authorization endpoint', () => {
tiktokClient.generateAuthorization({ state: 'test-state' });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/accounts/1/tiktok/authorization',
{ state: 'test-state' }
);
});
});
});
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
@@ -65,6 +66,7 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
tiktok: '',
linkedin: '',
twitter: '',
},
@@ -0,0 +1,88 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
badges: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
link: 'i-lucide-link',
number: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
type: {
type: String,
default: 'resolution',
validator: value => ['pre-chat', 'resolution'].includes(value),
},
});
const { t } = useI18n();
const attributeConfig = {
'pre-chat': {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
},
};
const config = computed(
() => attributeConfig[props.type] || attributeConfig.resolution
);
</script>
<template>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -14,6 +14,7 @@ export function useChannelIcon(inbox) {
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Voice': 'i-woot-voice',
'Channel::Tiktok': 'i-woot-tiktok',
};
const providerIconMap = {
@@ -61,6 +61,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-woot-instagram');
});
it('returns correct icon for TikTok channel', () => {
const inbox = { channel_type: 'Channel::Tiktok' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-tiktok');
});
describe('TwilioSms channel', () => {
it('returns chat icon for regular Twilio SMS channel', () => {
const inbox = { channel_type: 'Channel::TwilioSms' };
@@ -28,6 +28,7 @@ import ImageBubble from './bubbles/Image.vue';
import FileBubble from './bubbles/File.vue';
import AudioBubble from './bubbles/Audio.vue';
import VideoBubble from './bubbles/Video.vue';
import EmbedBubble from './bubbles/Embed.vue';
import InstagramStoryBubble from './bubbles/InstagramStory.vue';
import EmailBubble from './bubbles/Email/Index.vue';
import UnsupportedBubble from './bubbles/Unsupported.vue';
@@ -317,6 +318,7 @@ const componentToRender = computed(() => {
if (fileType === ATTACHMENT_TYPES.AUDIO) return AudioBubble;
if (fileType === ATTACHMENT_TYPES.VIDEO) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.IG_REEL) return VideoBubble;
if (fileType === ATTACHMENT_TYPES.EMBED) return EmbedBubble;
if (fileType === ATTACHMENT_TYPES.LOCATION) return LocationBubble;
}
// Attachment content is the name of the contact
@@ -20,6 +20,7 @@ const {
isAWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
} = useInbox();
const {
@@ -60,7 +61,8 @@ const isSent = computed(() => {
isAFacebookInbox.value ||
isASmsInbox.value ||
isATelegramChannel.value ||
isAnInstagramChannel.value
isAnInstagramChannel.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.SENT;
}
@@ -78,7 +80,8 @@ const isDelivered = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isASmsInbox.value ||
isAFacebookInbox.value
isAFacebookInbox.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.DELIVERED;
}
@@ -100,7 +103,8 @@ const isRead = computed(() => {
isAWhatsAppChannel.value ||
isATwilioChannel.value ||
isAFacebookInbox.value ||
isAnInstagramChannel.value
isAnInstagramChannel.value ||
isATiktokChannel.value
) {
return sourceId.value && status.value === MESSAGE_STATUS.READ;
}
@@ -0,0 +1,30 @@
<script setup>
import { computed } from 'vue';
import BaseBubble from './Base.vue';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
const { attachments } = useMessageContext();
const { t } = useI18n();
const attachment = computed(() => {
return attachments.value[0];
});
</script>
<template>
<BaseBubble class="overflow-hidden p-3" data-bubble-name="embed">
<div
class="w-full max-w-[360px] sm:max-w-[420px] min-h-[520px] h-[70vh] max-h-[680px]"
>
<iframe
class="w-full h-full border-0 rounded-lg"
:title="t('CHAT_LIST.ATTACHMENTS.embed.CONTENT')"
:src="attachment.dataUrl"
loading="lazy"
allow="autoplay; encrypted-media; picture-in-picture"
allowfullscreen
/>
</div>
</BaseBubble>
</template>
@@ -49,6 +49,7 @@ export const ATTACHMENT_TYPES = {
STORY_MENTION: 'story_mention',
CONTACT: 'contact',
IG_REEL: 'ig_reel',
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
};
@@ -23,6 +23,10 @@ const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const isActive = computed(() => {
const { key } = props.channel;
if (Object.keys(props.enabledFeatures).length === 0) {
@@ -44,6 +48,10 @@ const isActive = computed(() => {
);
}
if (key === 'tiktok') {
return props.enabledFeatures.channel_tiktok && hasTiktokConfigured.value;
}
if (key === 'voice') {
return props.enabledFeatures.channel_voice;
}
@@ -57,6 +65,7 @@ const isActive = computed(() => {
'telegram',
'line',
'instagram',
'tiktok',
'voice',
].includes(key);
});
@@ -121,6 +121,7 @@ const {
isAnInstagramChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isATiktokChannel,
} = useInbox();
const inboxHasFeature = feature => {
@@ -213,6 +214,9 @@ const replyWindowLink = computed(() => {
if (isAWhatsAppCloudChannel.value) {
return REPLY_POLICY.WHATSAPP_CLOUD;
}
if (isATiktokChannel.value) {
return REPLY_POLICY.TIKTOK;
}
if (!isAPIInbox.value) {
return REPLY_POLICY.TWILIO_WHATSAPP;
}
@@ -227,6 +231,9 @@ const replyWindowLinkText = computed(() => {
) {
return t('CONVERSATION.24_HOURS_WINDOW');
}
if (isATiktokChannel.value) {
return t('CONVERSATION.48_HOURS_WINDOW');
}
if (!isAPIInbox.value) {
return t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
}
@@ -234,6 +234,9 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
if (this.isATwilioWhatsAppChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
@@ -48,6 +48,7 @@ const mockStore = createStore({
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
},
@@ -215,6 +216,12 @@ describe('useInbox', () => {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
});
});
@@ -266,6 +273,7 @@ describe('useInbox', () => {
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
];
@@ -17,6 +17,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -24,6 +25,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
@@ -128,6 +130,10 @@ export const useInbox = (inboxId = null) => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isATiktokChannel = computed(() => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
@@ -150,6 +156,7 @@ export const useInbox = (inboxId = null) => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
};
};
@@ -109,6 +109,11 @@ export const FORMATTING = {
nodes: [],
menu: [],
},
'Channel::Tiktok': {
marks: [],
nodes: [],
menu: [],
},
// Special contexts (not actual channels)
'Context::Default': {
marks: ['strong', 'em', 'code', 'link', 'strike'],
+1
View File
@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
+6
View File
@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
@@ -28,6 +29,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
@@ -44,6 +46,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -136,6 +139,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
@@ -38,6 +38,9 @@ describe('#Inbox Helpers', () => {
it('should return correct class for Email', () => {
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
});
it('should return correct class for TikTok', () => {
expect(getInboxClassByType(INBOX_TYPES.TIKTOK)).toEqual('brand-tiktok');
});
});
describe('getInboxIconByType', () => {
@@ -80,6 +83,10 @@ describe('#Inbox Helpers', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns correct icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK)).toBe('i-ri-tiktok-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
@@ -102,6 +109,12 @@ describe('#Inbox Helpers', () => {
);
});
it('returns correct line icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK, null, 'line')).toBe(
'i-ri-tiktok-line'
);
});
it('returns correct line icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
'i-ri-chat-1-line'
@@ -129,6 +129,10 @@
"ENABLE_REGEX": {
"LABEL": "Enable regex validation"
}
},
"BADGES": {
"PRE_CHAT": "Pre-chat",
"RESOLUTION": "Resolution"
}
}
}
@@ -102,6 +102,9 @@
},
"contact": {
"CONTENT": "Shared contact"
},
"embed": {
"CONTENT": "Embedded content"
}
},
"CHAT_SORT_BY_FILTER": {
@@ -481,6 +481,9 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
"LINKEDIN": {
"PLACEHOLDER": "Add LinkedIn"
},
@@ -32,6 +32,7 @@
"LOADING_CONVERSATIONS": "Loading Conversations",
"CANNOT_REPLY": "You cannot reply due to",
"24_HOURS_WINDOW": "24 hour message window restriction",
"48_HOURS_WINDOW": "48 hour message window restriction",
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
"ASSIGN_TO_ME": "Assign to me",
@@ -57,7 +58,7 @@
},
"UPLOADING_ATTACHMENTS": "Uploading attachments...",
"REPLIED_TO_STORY": "Replied to your story",
"UNSUPPORTED_MESSAGE": "This message is unsupported. You can view this message on the Facebook / Instagram app.",
"UNSUPPORTED_MESSAGE": "This message is unsupported. To view it, please open it on the original platform.",
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
@@ -57,6 +57,13 @@
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You wont be able to send/receive Instagram messages from this inbox anymore."
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",
"ERROR_MESSAGE": "There was an error connecting to Twitter, please try again",
@@ -471,6 +478,10 @@
"TITLE": "Instagram",
"DESCRIPTION": "Connect your instagram account"
},
"TIKTOK": {
"TITLE": "TikTok",
"DESCRIPTION": "Connect your TikTok account"
},
"VOICE": {
"TITLE": "Voice",
"DESCRIPTION": "Integrate with Twilio Voice"
@@ -1009,6 +1020,7 @@
"LINE": "Line",
"API": "API Channel",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"VOICE": "Voice"
}
}
@@ -62,6 +62,7 @@ export default {
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
},
@@ -15,6 +15,7 @@ export default {
{ key: 'github', icon: 'github', link: 'https://github.com/' },
{ key: 'instagram', icon: 'instagram', link: 'https://instagram.com/' },
{ key: 'telegram', icon: 'telegram', link: 'https://t.me/' },
{ key: 'tiktok', icon: 'tiktok', link: 'https://tiktok.com/@' },
],
};
},
@@ -1,27 +1,49 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
import EditAttribute from './EditAttribute.vue';
import SettingsLayout from '../SettingsLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import AttributeListItem from 'dashboard/components-next/ConversationWorkflow/AttributeListItem.vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import {
useStoreGetters,
useStore,
useMapGetter,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
const { t } = useI18n();
const getters = useStoreGetters();
const store = useStore();
const { currentAccount } = useAccount();
const inboxes = useMapGetter('inboxes/getInboxes');
const showAddPopup = ref(false);
const [showAddPopup, toggleAddPopup] = useToggle(false);
const selectedTabIndex = ref(0);
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
const [showEditPopup, toggleEditPopup] = useToggle(false);
const [showDeletePopup, toggleDeletePopup] = useToggle(false);
const selectedAttribute = ref({});
const openAddPopup = () => {
showAddPopup.value = true;
toggleAddPopup(true);
};
const hideAddPopup = () => {
showAddPopup.value = false;
toggleAddPopup(false);
};
const hideEditPopup = () => {
toggleEditPopup(false);
selectedAttribute.value = {};
};
const closeDelete = () => {
toggleDeletePopup(false);
selectedAttribute.value = {};
};
const tabs = computed(() => {
@@ -37,6 +59,10 @@ const tabs = computed(() => {
];
});
const tabsForTabBar = computed(() =>
tabs.value.map(tab => ({ label: tab.name, key: tab.key }))
);
onMounted(() => {
store.dispatch('attributes/get');
});
@@ -49,9 +75,75 @@ const attributes = computed(() =>
getters['attributes/getAttributesByModel'].value(attributeModel.value)
);
const onClickTabChange = index => {
selectedTabIndex.value = index;
const onClickTabChange = tab => {
selectedTabIndex.value = tab.key;
};
const handleEditAttribute = attribute => {
selectedAttribute.value = attribute;
toggleEditPopup(true);
};
const handleDeleteAttribute = attribute => {
selectedAttribute.value = attribute;
toggleDeletePopup(true);
};
const confirmDeleteAttribute = async () => {
try {
await store.dispatch('attributes/delete', selectedAttribute.value.id);
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
closeDelete();
} catch (error) {
const errorMessage =
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const requiredAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const hasPreChatBadge = attribute => {
return (inboxes.value || []).some(inbox => {
const fields =
inbox?.pre_chat_form_options?.pre_chat_fields ||
inbox?.channel?.pre_chat_form_options?.pre_chat_fields ||
[];
return fields.some(field => field.name === attribute.attribute_key);
});
};
const buildBadges = attribute => {
const badges = [];
if (hasPreChatBadge(attribute)) {
badges.push({
type: 'pre-chat',
});
}
if (
attribute.attribute_model === 'conversation_attribute' &&
requiredAttributeKeys.value.includes(attribute.attribute_key)
) {
badges.push({
type: 'resolution',
});
}
return badges;
};
const derivedAttributes = computed(() =>
attributes.value.map(attribute => ({
...attribute,
label: attribute.attribute_display_name,
type: attribute.attribute_display_type,
value: attribute.attribute_key,
badges: buildBadges(attribute),
}))
);
</script>
<template>
@@ -77,27 +169,25 @@ const onClickTabChange = index => {
</template>
</BaseSettingsHeader>
</template>
<template #preBody>
<woot-tabs
class="font-medium [&_ul]:p-0 mb-4"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="(tab, index) in tabs"
:key="tab.key"
:index="index"
:name="tab.name"
:show-badge="false"
is-compact
/>
</woot-tabs>
</template>
<template #body>
<CustomAttribute
:key="attributeModel"
:attribute-model="attributeModel"
/>
<div class="flex flex-col gap-6">
<TabBar
:tabs="tabsForTabBar"
:initial-active-tab="selectedTabIndex"
class="max-w-xl"
@tab-changed="onClickTabChange"
/>
<div class="grid gap-3">
<AttributeListItem
v-for="attribute in derivedAttributes"
:key="attribute.id"
:attribute="attribute"
:badges="attribute.badges"
@edit="handleEditAttribute"
@delete="handleDeleteAttribute"
/>
</div>
</div>
</template>
<AddAttribute
v-if="showAddPopup"
@@ -105,5 +195,34 @@ const onClickTabChange = index => {
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
:is-updating="uiFlags.isUpdating"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-confirm-delete-modal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
attributeName: selectedAttribute.attribute_display_name,
})
"
:message="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.MESSAGE')"
:confirm-text="`${$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
selectedAttribute.attribute_display_name || ''
}`"
:reject-text="$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO')"
:confirm-value="selectedAttribute.attribute_display_name"
:confirm-place-holder-text="
$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
attributeName: selectedAttribute.attribute_display_name,
})
"
@on-confirm="confirmDeleteAttribute"
@on-close="closeDelete"
/>
</SettingsLayout>
</template>
@@ -10,6 +10,7 @@ import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
import Instagram from './channels/Instagram.vue';
import Tiktok from './channels/Tiktok.vue';
import Voice from './channels/Voice.vue';
const channelViewList = {
@@ -23,6 +24,7 @@ const channelViewList = {
line: Line,
telegram: Telegram,
instagram: Instagram,
tiktok: Tiktok,
voice: Voice,
};
@@ -16,9 +16,13 @@ const globalConfig = useMapGetter('globalConfig/get');
const enabledFeatures = ref({});
const hasTiktokConfigured = computed(() => {
return window.chatwootConfig?.tiktokAppId;
});
const channelList = computed(() => {
const { apiChannelName } = globalConfig.value;
return [
const channels = [
{
key: 'website',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE'),
@@ -73,13 +77,25 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.DESCRIPTION'),
icon: 'i-woot-instagram',
},
{
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-woot-voice',
},
];
if (hasTiktokConfigured.value) {
channels.push({
key: 'tiktok',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.DESCRIPTION'),
icon: 'i-woot-tiktok',
});
}
channels.push({
key: 'voice',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE'),
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.DESCRIPTION'),
icon: 'i-woot-voice',
});
return channels;
});
const initializeEnabledFeatures = async () => {
@@ -9,6 +9,7 @@ import SettingsSection from '../../../../components/SettingsSection.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import InstagramReauthorize from './channels/instagram/Reauthorize.vue';
import TiktokReauthorize from './channels/tiktok/Reauthorize.vue';
import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import GoogleReauthorize from './channels/google/Reauthorize.vue';
@@ -48,6 +49,7 @@ export default {
GoogleReauthorize,
NextButton,
InstagramReauthorize,
TiktokReauthorize,
WhatsappReauthorize,
DuplicateInboxBanner,
Editor,
@@ -248,6 +250,9 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
// Check if a instagram inbox exists with the same instagram_id
hasDuplicateInstagramInbox() {
const instagramId = this.inbox.instagram_id;
@@ -524,6 +529,7 @@ export default {
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
<InstagramReauthorize v-if="instagramUnauthorized" :inbox="inbox" />
<TiktokReauthorize v-if="tiktokUnauthorized" :inbox="inbox" />
<WhatsappReauthorize
v-if="whatsappUnauthorized"
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
@@ -1,63 +1,46 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { useAccount } from 'dashboard/composables/useAccount';
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import instagramClient from 'dashboard/api/channel/instagramClient';
import Button from 'dashboard/components-next/button/Button.vue';
export default {
setup() {
const { accountId } = useAccount();
return {
accountId,
v$: useVuelidate(),
};
},
data() {
return {
isCreating: false,
hasError: false,
errorStateMessage: '',
errorStateDescription: '',
isRequestingAuthorization: false,
};
},
const { t } = useI18n();
mounted() {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
if (errorMessage) {
this.hasError = true;
if (errorCode === '400') {
this.errorStateMessage = errorMessage;
this.errorStateDescription = this.$t(
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH'
);
} else {
this.errorStateMessage = this.$t(
'INBOX_MGMT.ADD.INSTAGRAM.ERROR_MESSAGE'
);
this.errorStateDescription = errorMessage;
}
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
if (errorMessage) {
hasError.value = true;
if (errorCode === '400') {
errorStateMessage.value = errorMessage;
errorStateDescription.value = t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_AUTH');
} else {
errorStateMessage.value = t('INBOX_MGMT.ADD.INSTAGRAM.ERROR_MESSAGE');
errorStateDescription.value = errorMessage;
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
},
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
});
methods: {
async requestAuthorization() {
this.isRequestingAuthorization = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
const requestAuthorization = async () => {
isRequestingAuthorization.value = true;
const response = await instagramClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
},
},
window.location.href = url;
};
</script>
@@ -81,38 +64,15 @@ export default {
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
</p>
<button
class="flex items-center justify-center px-8 py-3.5 gap-2 text-white rounded-full bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45] hover:shadow-lg transition-all duration-300 min-w-[240px] overflow-hidden"
<Button
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#833AB4] via-[#FD1D1D] to-[#FCAF45]"
lg
icon="i-ri-instagram-line"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM')"
@click="requestAuthorization()"
>
<span class="i-ri-instagram-line size-5" />
<span class="text-base font-medium">
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONTINUE_WITH_INSTAGRAM') }}
</span>
<span v-if="isRequestingAuthorization" class="ml-2">
<svg
class="w-5 h-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
</button>
/>
</div>
</div>
</div>
@@ -0,0 +1,79 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const hasError = ref(false);
const errorStateMessage = ref('');
const errorStateDescription = ref('');
const isRequestingAuthorization = ref(false);
onMounted(() => {
const urlParams = new URLSearchParams(window.location.search);
// TODO: Handle error type
// const errorType = urlParams.get('error_type');
const errorCode = urlParams.get('code');
const errorMessage = urlParams.get('error_message');
if (errorMessage) {
hasError.value = true;
if (errorCode === '400') {
errorStateMessage.value = errorMessage;
errorStateDescription.value = t('INBOX_MGMT.ADD.TIKTOK.ERROR_AUTH');
} else {
errorStateMessage.value = t('INBOX_MGMT.ADD.TIKTOK.ERROR_MESSAGE');
errorStateDescription.value = errorMessage;
}
}
// User need to remove the error params from the url to avoid the error to be shown again after page reload, so that user can try again
const cleanURL = window.location.pathname;
window.history.replaceState({}, document.title, cleanURL);
});
const requestAuthorization = async () => {
isRequestingAuthorization.value = true;
const response = await tiktokClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
};
</script>
<template>
<div class="h-full p-6 w-full max-w-full flex-shrink-0 flex-grow-0">
<div class="flex flex-col items-center justify-start h-full text-center">
<div v-if="hasError" class="max-w-lg mx-auto text-center">
<h5>{{ errorStateMessage }}</h5>
<p
v-if="errorStateDescription"
v-dompurify-html="errorStateDescription"
/>
</div>
<div
v-else
class="flex flex-col items-center justify-center px-8 py-10 text-center rounded-2xl outline outline-1 outline-n-weak"
>
<h6 class="text-2xl font-medium">
{{ $t('INBOX_MGMT.ADD.TIKTOK.CONNECT_YOUR_TIKTOK_PROFILE') }}
</h6>
<p class="py-6 text-sm text-n-slate-11">
{{ $t('INBOX_MGMT.ADD.TIKTOK.HELP') }}
</p>
<Button
class="text-white !rounded-full !px-6 bg-gradient-to-r from-[#00f2ea] via-[#ff0050] to-[#000000]"
lg
icon="i-ri-tiktok-line"
:disabled="isRequestingAuthorization"
:is-loading="isRequestingAuthorization"
:label="$t('INBOX_MGMT.ADD.TIKTOK.CONTINUE_WITH_TIKTOK')"
@click="requestAuthorization()"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,37 @@
<script setup>
import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import tiktokClient from 'dashboard/api/channel/tiktokClient';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
const { t } = useI18n();
const isRequestingAuthorization = ref(false);
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await tiktokClient.generateAuthorization();
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.TIKTOK.ERROR_AUTH'));
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<InboxReconnectionRequired
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
</template>
@@ -29,6 +29,7 @@ const i18nMap = {
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
'Channel::Voice': 'VOICE',
};
@@ -165,6 +165,13 @@ export const getters = {
item.channel_type === INBOX_TYPES.INSTAGRAM
);
},
getTiktokInboxByBusinessId: $state => businessId => {
return $state.records.find(
item =>
item.business_id === businessId &&
item.channel_type === INBOX_TYPES.TIKTOK
);
},
};
const sendAnalyticsEvent = channelType => {
@@ -71,4 +71,12 @@ export default [
instagram_id: 123456789,
provider: 'default',
},
{
id: 8,
channel_id: 8,
name: 'Test TikTok 1',
channel_type: 'Channel::Tiktok',
business_id: 123456789,
provider: 'default',
},
];
@@ -27,7 +27,7 @@ describe('#getters', () => {
it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList };
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(8);
});
it('getInbox', () => {
@@ -95,6 +95,18 @@ describe('#getters', () => {
});
});
it('getTiktokInboxByBusinessId', () => {
const state = { records: inboxList };
expect(getters.getTiktokInboxByBusinessId(state)(123456789)).toEqual({
id: 8,
channel_id: 8,
name: 'Test TikTok 1',
channel_type: 'Channel::Tiktok',
business_id: 123456789,
provider: 'default',
});
});
describe('getFilteredWhatsAppTemplates', () => {
it('returns empty array when inbox not found', () => {
const state = { records: [] };
+1 -1
View File
@@ -76,7 +76,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
welcomeDescription: chatwootSettings.welcomeDescription || '',
availableMessage: chatwootSettings.availableMessage || '',
unavailableMessage: chatwootSettings.unavailableMessage || '',
enableFileUpload: chatwootSettings.enableFileUpload ?? true,
enableFileUpload: chatwootSettings.enableFileUpload,
enableEmojiPicker: chatwootSettings.enableEmojiPicker ?? true,
enableEndConversation: chatwootSettings.enableEndConversation ?? true,
@@ -45,6 +45,7 @@
"bot-outline": "M17.753 14a2.25 2.25 0 0 1 2.25 2.25v.905a3.75 3.75 0 0 1-1.307 2.846C17.13 21.345 14.89 22 12 22c-2.89 0-5.128-.656-6.691-2a3.75 3.75 0 0 1-1.306-2.843v-.908A2.25 2.25 0 0 1 6.253 14h11.5Zm0 1.5h-11.5a.75.75 0 0 0-.75.75v.908c0 .655.286 1.278.784 1.706C7.545 19.945 9.44 20.502 12 20.502c2.56 0 4.458-.557 5.719-1.64a2.25 2.25 0 0 0 .784-1.706v-.906a.75.75 0 0 0-.75-.75ZM11.898 2.008 12 2a.75.75 0 0 1 .743.648l.007.102V3.5h3.5a2.25 2.25 0 0 1 2.25 2.25v4.505a2.25 2.25 0 0 1-2.25 2.25h-8.5a2.25 2.25 0 0 1-2.25-2.25V5.75A2.25 2.25 0 0 1 7.75 3.5h3.5v-.749a.75.75 0 0 1 .648-.743L12 2l-.102.007ZM16.25 5h-8.5a.75.75 0 0 0-.75.75v4.505c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75V5.75a.75.75 0 0 0-.75-.75Zm-6.5 1.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm4.492 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z",
"brand-facebook-outline": "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z",
"brand-instagram-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
"brand-tiktok-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
"brand-github-outline": "M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z",
"brand-line-outline": "M19.365 9.863c.349 0 .63.285.63.631 0 .345-.281.63-.63.63H17.61v1.125h1.755c.349 0 .63.283.63.63 0 .344-.281.629-.63.629h-2.386c-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63h2.386c.346 0 .627.285.627.63 0 .349-.281.63-.63.63H17.61v1.125h1.755zm-3.855 3.016c0 .27-.174.51-.432.596-.064.021-.133.031-.199.031-.211 0-.391-.09-.51-.25l-2.443-3.317v2.94c0 .344-.279.629-.631.629-.346 0-.626-.285-.626-.629V8.108c0-.27.173-.51.43-.595.06-.023.136-.033.194-.033.195 0 .375.104.495.254l2.462 3.33V8.108c0-.345.282-.63.63-.63.345 0 .63.285.63.63v4.771zm-5.741 0c0 .344-.282.629-.631.629-.345 0-.627-.285-.627-.629V8.108c0-.345.282-.63.63-.63.346 0 .628.285.628.63v4.771zm-2.466.629H4.917c-.345 0-.63-.285-.63-.629V8.108c0-.345.285-.63.63-.63.348 0 .63.285.63.63v4.141h1.756c.348 0 .629.283.629.63 0 .344-.282.629-.629.629M24 10.314C24 4.943 18.615.572 12 .572S0 4.943 0 10.314c0 4.811 4.27 8.842 10.035 9.608.391.082.923.258 1.058.59.12.301.079.766.038 1.08l-.164 1.02c-.045.301-.24 1.186 1.049.645 1.291-.539 6.916-4.078 9.436-6.975C23.176 14.393 24 12.458 24 10.314",
"brand-linkedin-outline": "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
+1
View File
@@ -5,6 +5,7 @@ export const REPLY_POLICY = {
'https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#sending-non-template-messages-within-a-24-hour-session',
WHATSAPP_CLOUD:
'https://business.whatsapp.com/policy#:~:text=You%20may%20reply%20to%20a,messages%20via%20approved%20Message%20Templates.',
TIKTOK: 'https://business-api.tiktok.com/portal/docs?id=1832184236919810',
};
export const CHANGELOG_API_URL = 'https://hub.2.chatwoot.com/changelogs';
@@ -8,6 +8,8 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 2000,
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#send-a-text-message
INSTAGRAM: 1000,
// https://business-api.tiktok.com/portal/docs?id=1832184403754242
TIKTOK: 6000,
// https://www.twilio.com/docs/glossary/what-sms-character-limit
TWILIO_SMS: 320,
// https://help.twilio.com/articles/360033806753-Maximum-Message-Length-with-Twilio-Programmable-Messaging
@@ -14,6 +14,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
@@ -21,6 +22,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
@@ -115,6 +117,8 @@ export default {
badgeKey = this.twilioBadge;
} else if (this.isAWhatsAppChannel) {
badgeKey = 'whatsapp';
} else if (this.isATiktokChannel) {
badgeKey = 'tiktok';
}
return badgeKey || this.channelType;
},
@@ -127,6 +131,9 @@ export default {
isAnInstagramChannel() {
return this.channelType === INBOX_TYPES.INSTAGRAM;
},
isATiktokChannel() {
return this.channelType === INBOX_TYPES.TIKTOK;
},
},
methods: {
inboxHasFeature(feature) {
@@ -11,6 +11,7 @@ import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { DirectUpload } from 'activestorage';
import { mapGetters } from 'vuex';
import { emitter } from 'shared/helpers/mitt';
import { useAttachments } from '../composables/useAttachments';
export default {
components: { FluentIcon, FileUpload, Spinner },
@@ -20,13 +21,16 @@ export default {
default: () => {},
},
},
setup() {
const { canHandleAttachments } = useAttachments();
return { canHandleAttachments };
},
data() {
return { isUploading: false };
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
}),
fileUploadSizeLimit() {
return resolveMaximumFileUploadSize(
@@ -46,7 +50,7 @@ export default {
methods: {
handleClipboardPaste(e) {
// If file picker is not enabled, do not allow paste
if (!this.shouldShowFilePicker) return;
if (!this.canHandleAttachments) return;
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
// items is a DataTransferItemList object which does not have forEach method
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
import ChatSendButton from 'widget/components/ChatSendButton.vue';
import configMixin from '../mixins/configMixin';
import { useAttachments } from '../composables/useAttachments';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
@@ -18,7 +18,6 @@ export default {
FluentIcon,
ResizableTextArea,
},
mixins: [configMixin],
props: {
onSendMessage: {
type: Function,
@@ -29,6 +28,18 @@ export default {
default: () => {},
},
},
setup() {
const {
canHandleAttachments,
shouldShowEmojiPicker,
hasEmojiPickerEnabled,
} = useAttachments();
return {
canHandleAttachments,
shouldShowEmojiPicker,
hasEmojiPickerEnabled,
};
},
data() {
return {
userInput: '',
@@ -41,15 +52,10 @@ export default {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
shouldShowFilePicker: 'appConfig/getShouldShowFilePicker',
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
}),
showAttachment() {
return (
this.shouldShowFilePicker &&
this.hasAttachmentsEnabled &&
this.userInput.length === 0
);
return this.canHandleAttachments && this.userInput.length === 0;
},
showSendButton() {
return this.userInput.length > 0;
@@ -0,0 +1,224 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAttachments } from '../useAttachments';
import { useStore } from 'vuex';
import { computed } from 'vue';
// Mock Vue's useStore
vi.mock('vuex', () => ({
useStore: vi.fn(),
}));
// Mock Vue's computed
vi.mock('vue', () => ({
computed: vi.fn(fn => ({ value: fn() })),
}));
describe('useAttachments', () => {
let mockStore;
let mockGetters;
beforeEach(() => {
// Reset window.chatwootWebChannel
delete window.chatwootWebChannel;
// Create mock store
mockGetters = {};
mockStore = {
getters: mockGetters,
};
vi.mocked(useStore).mockReturnValue(mockStore);
// Mock computed to return a reactive-like object
vi.mocked(computed).mockImplementation(fn => ({ value: fn() }));
});
afterEach(() => {
vi.clearAllMocks();
});
describe('shouldShowFilePicker', () => {
it('returns value from store getter', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
const { shouldShowFilePicker } = useAttachments();
expect(shouldShowFilePicker.value).toBe(true);
});
it('returns undefined when not set in store', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
const { shouldShowFilePicker } = useAttachments();
expect(shouldShowFilePicker.value).toBeUndefined();
});
});
describe('hasAttachmentsEnabled', () => {
it('returns true when attachments are enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['attachments', 'emoji'],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(true);
});
it('returns false when attachments are not enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['emoji'],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when channel config has no enabled features', () => {
window.chatwootWebChannel = {
enabledFeatures: [],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when channel config is missing', () => {
window.chatwootWebChannel = undefined;
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when enabledFeatures is missing', () => {
window.chatwootWebChannel = {};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
});
describe('canHandleAttachments', () => {
beforeEach(() => {
// Set up a default channel config
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
});
it('prioritizes SDK flag when explicitly set to true', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
it('prioritizes SDK flag when explicitly set to false', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = false;
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('falls back to inbox settings when SDK flag is undefined', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
window.chatwootWebChannel = {
enabledFeatures: ['emoji'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('prioritizes SDK false over inbox settings true', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = false;
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('prioritizes SDK true over inbox settings false', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
window.chatwootWebChannel = {
enabledFeatures: ['emoji'], // no attachments
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
});
describe('hasEmojiPickerEnabled', () => {
it('returns true when emoji picker is enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['emoji_picker', 'attachments'],
};
const { hasEmojiPickerEnabled } = useAttachments();
expect(hasEmojiPickerEnabled.value).toBe(true);
});
it('returns false when emoji picker is not enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { hasEmojiPickerEnabled } = useAttachments();
expect(hasEmojiPickerEnabled.value).toBe(false);
});
});
describe('shouldShowEmojiPicker', () => {
it('returns value from store getter', () => {
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
const { shouldShowEmojiPicker } = useAttachments();
expect(shouldShowEmojiPicker.value).toBe(true);
});
});
describe('integration test', () => {
it('returns all expected properties', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
window.chatwootWebChannel = {
enabledFeatures: ['attachments', 'emoji_picker'],
};
const result = useAttachments();
expect(result).toHaveProperty('shouldShowFilePicker');
expect(result).toHaveProperty('shouldShowEmojiPicker');
expect(result).toHaveProperty('hasAttachmentsEnabled');
expect(result).toHaveProperty('hasEmojiPickerEnabled');
expect(result).toHaveProperty('canHandleAttachments');
expect(Object.keys(result)).toHaveLength(5);
});
});
});
@@ -0,0 +1,42 @@
import { computed } from 'vue';
import { useStore } from 'vuex';
export function useAttachments() {
const store = useStore();
const shouldShowFilePicker = computed(
() => store.getters['appConfig/getShouldShowFilePicker']
);
const shouldShowEmojiPicker = computed(
() => store.getters['appConfig/getShouldShowEmojiPicker']
);
const hasAttachmentsEnabled = computed(() => {
const channelConfig = window.chatwootWebChannel;
return channelConfig?.enabledFeatures?.includes('attachments') || false;
});
const hasEmojiPickerEnabled = computed(() => {
const channelConfig = window.chatwootWebChannel;
return channelConfig?.enabledFeatures?.includes('emoji_picker') || false;
});
const canHandleAttachments = computed(() => {
// If enableFileUpload was explicitly set via SDK, prioritize that
if (shouldShowFilePicker.value !== undefined) {
return shouldShowFilePicker.value;
}
// Otherwise, fall back to inbox settings only
return hasAttachmentsEnabled.value;
});
return {
shouldShowFilePicker,
shouldShowEmojiPicker,
hasAttachmentsEnabled,
hasEmojiPickerEnabled,
canHandleAttachments,
};
}
@@ -25,7 +25,7 @@ const state = {
welcomeDescription: '',
availableMessage: '',
unavailableMessage: '',
enableFileUpload: true,
enableFileUpload: undefined,
enableEmojiPicker: true,
enableEndConversation: true,
};
@@ -64,7 +64,7 @@ export const actions = {
welcomeDescription = '',
availableMessage = '',
unavailableMessage = '',
enableFileUpload = true,
enableFileUpload = undefined,
enableEmojiPicker = true,
enableEndConversation = true,
}