Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
386
research/chatwoot/app/javascript/widget/App.vue
Executable file
386
research/chatwoot/app/javascript/widget/App.vue
Executable file
@@ -0,0 +1,386 @@
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import { setHeader } from 'widget/helpers/axios';
|
||||
import addHours from 'date-fns/addHours';
|
||||
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
|
||||
import configMixin from './mixins/configMixin';
|
||||
import { getLocale } from './helpers/urlParamsHelper';
|
||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import {
|
||||
getExtraSpaceToScroll,
|
||||
loadedEventConfig,
|
||||
} from './helpers/IframeEventHelper';
|
||||
import {
|
||||
ON_AGENT_MESSAGE_RECEIVED,
|
||||
ON_CAMPAIGN_MESSAGE_CLICK,
|
||||
ON_UNREAD_MESSAGE_CLICK,
|
||||
} from './constants/widgetBusEvents';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
setup() {
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
const router = useRouter();
|
||||
const { isInWorkingHours } = useAvailability();
|
||||
|
||||
return { prefersDarkMode, router, isInWorkingHours };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isMobile: false,
|
||||
campaignsSnoozedTill: undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
activeCampaign: 'campaign/getActiveCampaign',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
hideMessageBubble: 'appConfig/getHideMessageBubble',
|
||||
isFetchingList: 'conversation/getIsFetchingList',
|
||||
isRightAligned: 'appConfig/isRightAligned',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
messageCount: 'conversation/getMessageCount',
|
||||
unreadMessageCount: 'conversation/getUnreadMessageCount',
|
||||
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
||||
showUnreadMessagesDialog: 'appConfig/getShowUnreadMessagesDialog',
|
||||
}),
|
||||
isIFrame() {
|
||||
return IFrameHelper.isIFrame();
|
||||
},
|
||||
isRNWebView() {
|
||||
return RNHelper.isRNWebView();
|
||||
},
|
||||
isRTL() {
|
||||
return this.$root.$i18n.locale
|
||||
? getLanguageDirection(this.$root.$i18n.locale)
|
||||
: false;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
activeCampaign() {
|
||||
this.setCampaignView();
|
||||
},
|
||||
isRTL: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
document.documentElement.dir = value ? 'rtl' : 'ltr';
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
||||
this.setLocale(locale);
|
||||
this.setWidgetColor(widgetColor);
|
||||
this.setWidgetColorVariable(widgetColor);
|
||||
setHeader(window.authToken);
|
||||
if (this.isIFrame) {
|
||||
this.registerListeners();
|
||||
this.sendLoadedEvent();
|
||||
} else {
|
||||
this.fetchOldConversations();
|
||||
this.fetchAvailableAgents(websiteToken);
|
||||
this.setLocale(getLocale(window.location.search));
|
||||
}
|
||||
if (this.isRNWebView) {
|
||||
this.registerListeners();
|
||||
this.sendRNWebViewLoadedEvent();
|
||||
}
|
||||
this.$store.dispatch('conversationAttributes/getAttributes');
|
||||
this.registerUnreadEvents();
|
||||
this.registerCampaignEvents();
|
||||
},
|
||||
methods: {
|
||||
...mapActions('appConfig', [
|
||||
'setAppConfig',
|
||||
'setReferrerHost',
|
||||
'setWidgetColor',
|
||||
'setBubbleVisibility',
|
||||
'setColorScheme',
|
||||
]),
|
||||
...mapActions('conversation', ['fetchOldConversations']),
|
||||
...mapActions('campaign', [
|
||||
'initCampaigns',
|
||||
'executeCampaign',
|
||||
'resetCampaign',
|
||||
]),
|
||||
...mapActions('agent', ['fetchAvailableAgents']),
|
||||
setWidgetColorVariable(widgetColor) {
|
||||
if (widgetColor) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--widget-color',
|
||||
widgetColor
|
||||
);
|
||||
}
|
||||
},
|
||||
scrollConversationToBottom() {
|
||||
const container = this.$el.querySelector('.conversation-wrap');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
},
|
||||
setBubbleLabel() {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'setBubbleLabel',
|
||||
label: this.$t('BUBBLE.LABEL'),
|
||||
});
|
||||
},
|
||||
setIframeHeight(isFixedHeight) {
|
||||
this.$nextTick(() => {
|
||||
const extraHeight = getExtraSpaceToScroll();
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'updateIframeHeight',
|
||||
isFixedHeight,
|
||||
extraHeight,
|
||||
});
|
||||
});
|
||||
},
|
||||
setLocale(localeWithVariation) {
|
||||
if (!localeWithVariation) return;
|
||||
const { enabledLanguages } = window.chatwootWebChannel;
|
||||
const localeWithoutVariation = localeWithVariation.split('_')[0];
|
||||
const hasLocaleWithoutVariation = enabledLanguages.some(
|
||||
lang => lang.iso_639_1_code === localeWithoutVariation
|
||||
);
|
||||
const hasLocaleWithVariation = enabledLanguages.some(
|
||||
lang => lang.iso_639_1_code === localeWithVariation
|
||||
);
|
||||
|
||||
if (hasLocaleWithVariation) {
|
||||
this.$root.$i18n.locale = localeWithVariation;
|
||||
} else if (hasLocaleWithoutVariation) {
|
||||
this.$root.$i18n.locale = localeWithoutVariation;
|
||||
}
|
||||
},
|
||||
registerUnreadEvents() {
|
||||
emitter.on(ON_AGENT_MESSAGE_RECEIVED, () => {
|
||||
const { name: routeName } = this.$route;
|
||||
if ((this.isWidgetOpen || !this.isIFrame) && routeName === 'messages') {
|
||||
this.$store.dispatch('conversation/setUserLastSeen');
|
||||
}
|
||||
this.setUnreadView();
|
||||
});
|
||||
emitter.on(ON_UNREAD_MESSAGE_CLICK, () => {
|
||||
this.router
|
||||
.replace({ name: 'messages' })
|
||||
.then(() => this.unsetUnreadView());
|
||||
});
|
||||
},
|
||||
registerCampaignEvents() {
|
||||
emitter.on(ON_CAMPAIGN_MESSAGE_CLICK, () => {
|
||||
if (this.shouldShowPreChatForm) {
|
||||
this.router.replace({ name: 'prechat-form' });
|
||||
} else {
|
||||
this.router.replace({ name: 'messages' });
|
||||
emitter.emit('execute-campaign', {
|
||||
campaignId: this.activeCampaign.id,
|
||||
});
|
||||
}
|
||||
this.unsetUnreadView();
|
||||
});
|
||||
emitter.on('execute-campaign', campaignDetails => {
|
||||
const { customAttributes, campaignId } = campaignDetails;
|
||||
const { websiteToken } = window.chatwootWebChannel;
|
||||
this.executeCampaign({ campaignId, websiteToken, customAttributes });
|
||||
this.router.replace({ name: 'messages' });
|
||||
});
|
||||
emitter.on('snooze-campaigns', () => {
|
||||
const expireBy = addHours(new Date(), 1);
|
||||
this.campaignsSnoozedTill = Number(expireBy);
|
||||
});
|
||||
},
|
||||
setCampaignView() {
|
||||
const { messageCount, activeCampaign } = this;
|
||||
const shouldSnoozeCampaign =
|
||||
this.campaignsSnoozedTill && this.campaignsSnoozedTill > Date.now();
|
||||
const isCampaignReadyToExecute =
|
||||
!isEmptyObject(activeCampaign) &&
|
||||
!messageCount &&
|
||||
!shouldSnoozeCampaign;
|
||||
if (this.isIFrame && isCampaignReadyToExecute) {
|
||||
this.router.replace({ name: 'campaigns' }).then(() => {
|
||||
this.setIframeHeight(true);
|
||||
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
|
||||
});
|
||||
}
|
||||
},
|
||||
setUnreadView() {
|
||||
const { unreadMessageCount } = this;
|
||||
if (!this.showUnreadMessagesDialog) {
|
||||
this.handleUnreadNotificationDot();
|
||||
} else if (
|
||||
this.isIFrame &&
|
||||
unreadMessageCount > 0 &&
|
||||
!this.isWidgetOpen
|
||||
) {
|
||||
this.router.replace({ name: 'unread-messages' }).then(() => {
|
||||
this.setIframeHeight(true);
|
||||
IFrameHelper.sendMessage({ event: 'setUnreadMode' });
|
||||
});
|
||||
this.handleUnreadNotificationDot();
|
||||
}
|
||||
},
|
||||
unsetUnreadView() {
|
||||
if (this.isIFrame) {
|
||||
IFrameHelper.sendMessage({ event: 'resetUnreadMode' });
|
||||
this.setIframeHeight(false);
|
||||
this.handleUnreadNotificationDot();
|
||||
}
|
||||
},
|
||||
handleUnreadNotificationDot() {
|
||||
const { unreadMessageCount } = this;
|
||||
if (this.isIFrame) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'handleNotificationDot',
|
||||
unreadMessageCount,
|
||||
});
|
||||
}
|
||||
},
|
||||
createWidgetEvents(message) {
|
||||
const { eventName } = message;
|
||||
const isWidgetTriggerEvent = eventName === 'webwidget.triggered';
|
||||
if (
|
||||
isWidgetTriggerEvent &&
|
||||
['unread-messages', 'campaigns'].includes(this.$route.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.$store.dispatch('events/create', { name: eventName });
|
||||
},
|
||||
registerListeners() {
|
||||
const { websiteToken } = window.chatwootWebChannel;
|
||||
window.addEventListener('message', e => {
|
||||
if (!IFrameHelper.isAValidEvent(e)) {
|
||||
return;
|
||||
}
|
||||
const message = IFrameHelper.getMessage(e);
|
||||
if (message.event === 'config-set') {
|
||||
this.setLocale(message.locale);
|
||||
this.setBubbleLabel();
|
||||
this.fetchOldConversations().then(() => this.setUnreadView());
|
||||
this.fetchAvailableAgents(websiteToken);
|
||||
this.setAppConfig(message);
|
||||
this.$store.dispatch('contacts/get');
|
||||
this.setCampaignReadData(message.campaignsSnoozedTill);
|
||||
} else if (message.event === 'widget-visible') {
|
||||
this.scrollConversationToBottom();
|
||||
} else if (message.event === 'change-url') {
|
||||
const { referrerURL, referrerHost } = message;
|
||||
this.initCampaigns({
|
||||
currentURL: referrerURL,
|
||||
websiteToken,
|
||||
isInBusinessHours: this.isInWorkingHours,
|
||||
});
|
||||
window.referrerURL = referrerURL;
|
||||
this.setReferrerHost(referrerHost);
|
||||
} else if (message.event === 'toggle-close-button') {
|
||||
this.isMobile = message.isMobile;
|
||||
} else if (message.event === 'push-event') {
|
||||
this.createWidgetEvents(message);
|
||||
} else if (message.event === 'set-label') {
|
||||
this.$store.dispatch('conversationLabels/create', message.label);
|
||||
} else if (message.event === 'remove-label') {
|
||||
this.$store.dispatch('conversationLabels/destroy', message.label);
|
||||
} else if (message.event === 'set-user') {
|
||||
this.$store.dispatch('contacts/setUser', message);
|
||||
} else if (message.event === 'set-custom-attributes') {
|
||||
this.$store.dispatch(
|
||||
'contacts/setCustomAttributes',
|
||||
message.customAttributes
|
||||
);
|
||||
} else if (message.event === 'delete-custom-attribute') {
|
||||
this.$store.dispatch(
|
||||
'contacts/deleteCustomAttribute',
|
||||
message.customAttribute
|
||||
);
|
||||
} else if (message.event === 'set-conversation-custom-attributes') {
|
||||
this.$store.dispatch(
|
||||
'conversation/setCustomAttributes',
|
||||
message.customAttributes
|
||||
);
|
||||
} else if (message.event === 'delete-conversation-custom-attribute') {
|
||||
this.$store.dispatch(
|
||||
'conversation/deleteCustomAttribute',
|
||||
message.customAttribute
|
||||
);
|
||||
} else if (message.event === 'set-locale') {
|
||||
this.setLocale(message.locale);
|
||||
this.setBubbleLabel();
|
||||
} else if (message.event === 'set-color-scheme') {
|
||||
this.setColorScheme(message.darkMode);
|
||||
} else if (message.event === 'toggle-open') {
|
||||
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
|
||||
|
||||
const shouldShowMessageView =
|
||||
['home'].includes(this.$route.name) &&
|
||||
message.isOpen &&
|
||||
this.messageCount;
|
||||
const shouldShowHomeView =
|
||||
!message.isOpen &&
|
||||
['unread-messages', 'campaigns'].includes(this.$route.name);
|
||||
|
||||
if (shouldShowMessageView) {
|
||||
this.router.replace({ name: 'messages' });
|
||||
}
|
||||
if (shouldShowHomeView) {
|
||||
this.$store.dispatch('conversation/setUserLastSeen');
|
||||
this.unsetUnreadView();
|
||||
this.router.replace({ name: 'home' });
|
||||
}
|
||||
if (!message.isOpen) {
|
||||
this.resetCampaign();
|
||||
}
|
||||
} else if (message.event === SDK_SET_BUBBLE_VISIBILITY) {
|
||||
this.setBubbleVisibility(message.hideMessageBubble);
|
||||
}
|
||||
});
|
||||
},
|
||||
sendLoadedEvent() {
|
||||
IFrameHelper.sendMessage(loadedEventConfig());
|
||||
},
|
||||
sendRNWebViewLoadedEvent() {
|
||||
RNHelper.sendMessage(loadedEventConfig());
|
||||
},
|
||||
setCampaignReadData(snoozedTill) {
|
||||
if (snoozedTill) {
|
||||
this.campaignsSnoozedTill = Number(snoozedTill);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="!conversationSize && isFetchingList"
|
||||
class="flex items-center justify-center flex-1 h-full bg-n-background"
|
||||
:class="{ dark: prefersDarkMode }"
|
||||
>
|
||||
<Spinner size="" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col justify-end h-full"
|
||||
:class="{
|
||||
'is-mobile': isMobile,
|
||||
'is-widget-right': isRightAligned,
|
||||
'is-bubble-hidden': hideMessageBubble,
|
||||
'is-flat-design': isWidgetStyleFlat,
|
||||
dark: prefersDarkMode,
|
||||
}"
|
||||
>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@import 'widget/assets/scss/woot.scss';
|
||||
</style>
|
||||
7
research/chatwoot/app/javascript/widget/api/agent.js
Normal file
7
research/chatwoot/app/javascript/widget/api/agent.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import endPoints from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
export const getAvailableAgents = async websiteToken => {
|
||||
const urlData = endPoints.getAvailableAgents(websiteToken);
|
||||
return API.get(urlData.url, { params: urlData.params });
|
||||
};
|
||||
7
research/chatwoot/app/javascript/widget/api/article.js
Normal file
7
research/chatwoot/app/javascript/widget/api/article.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import endPoints from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
export const getMostReadArticles = async (slug, locale) => {
|
||||
const urlData = endPoints.getMostReadArticles(slug, locale);
|
||||
return API.get(urlData.url, { params: urlData.params });
|
||||
};
|
||||
27
research/chatwoot/app/javascript/widget/api/campaign.js
Normal file
27
research/chatwoot/app/javascript/widget/api/campaign.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import endPoints from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
const getCampaigns = async websiteToken => {
|
||||
const urlData = endPoints.getCampaigns(websiteToken);
|
||||
return API.get(urlData.url, { params: urlData.params });
|
||||
};
|
||||
|
||||
const triggerCampaign = async ({
|
||||
campaignId,
|
||||
websiteToken,
|
||||
customAttributes,
|
||||
}) => {
|
||||
const urlData = endPoints.triggerCampaign({
|
||||
websiteToken,
|
||||
campaignId,
|
||||
customAttributes,
|
||||
});
|
||||
await API.post(
|
||||
urlData.url,
|
||||
{ ...urlData.data },
|
||||
{
|
||||
params: urlData.params,
|
||||
}
|
||||
);
|
||||
};
|
||||
export { getCampaigns, triggerCampaign };
|
||||
28
research/chatwoot/app/javascript/widget/api/contacts.js
Normal file
28
research/chatwoot/app/javascript/widget/api/contacts.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`;
|
||||
|
||||
export default {
|
||||
get() {
|
||||
return API.get(buildUrl('widget/contact'));
|
||||
},
|
||||
update(userObject) {
|
||||
return API.patch(buildUrl('widget/contact'), userObject);
|
||||
},
|
||||
setUser(identifier, userObject) {
|
||||
return API.patch(buildUrl('widget/contact/set_user'), {
|
||||
identifier,
|
||||
...userObject,
|
||||
});
|
||||
},
|
||||
setCustomAttributes(customAttributes = {}) {
|
||||
return API.patch(buildUrl('widget/contact'), {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
},
|
||||
deleteCustomAttribute(customAttribute) {
|
||||
return API.post(buildUrl('widget/contact/destroy_custom_attributes'), {
|
||||
custom_attributes: [customAttribute],
|
||||
});
|
||||
},
|
||||
};
|
||||
82
research/chatwoot/app/javascript/widget/api/conversation.js
Executable file
82
research/chatwoot/app/javascript/widget/api/conversation.js
Executable file
@@ -0,0 +1,82 @@
|
||||
import endPoints from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
const createConversationAPI = async content => {
|
||||
const urlData = endPoints.createConversation(content);
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendMessageAPI = async (content, replyTo = null) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo);
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendAttachmentAPI = async (attachment, replyTo = null) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, replyTo);
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const getMessagesAPI = async ({ before, after }) => {
|
||||
const urlData = endPoints.getConversation({ before, after });
|
||||
return API.get(urlData.url, { params: urlData.params });
|
||||
};
|
||||
|
||||
const getConversationAPI = async () => {
|
||||
return API.get(`/api/v1/widget/conversations${window.location.search}`);
|
||||
};
|
||||
|
||||
const toggleTyping = async ({ typingStatus }) => {
|
||||
return API.post(
|
||||
`/api/v1/widget/conversations/toggle_typing${window.location.search}`,
|
||||
{ typing_status: typingStatus }
|
||||
);
|
||||
};
|
||||
|
||||
const setUserLastSeenAt = async ({ lastSeen }) => {
|
||||
return API.post(
|
||||
`/api/v1/widget/conversations/update_last_seen${window.location.search}`,
|
||||
{ contact_last_seen_at: lastSeen }
|
||||
);
|
||||
};
|
||||
const sendEmailTranscript = async () => {
|
||||
return API.post(
|
||||
`/api/v1/widget/conversations/transcript${window.location.search}`
|
||||
);
|
||||
};
|
||||
const toggleStatus = async () => {
|
||||
return API.get(
|
||||
`/api/v1/widget/conversations/toggle_status${window.location.search}`
|
||||
);
|
||||
};
|
||||
|
||||
const setCustomAttributes = async customAttributes => {
|
||||
return API.post(
|
||||
`/api/v1/widget/conversations/set_custom_attributes${window.location.search}`,
|
||||
{
|
||||
custom_attributes: customAttributes,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const deleteCustomAttribute = async customAttribute => {
|
||||
return API.post(
|
||||
`/api/v1/widget/conversations/destroy_custom_attributes${window.location.search}`,
|
||||
{
|
||||
custom_attribute: [customAttribute],
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
createConversationAPI,
|
||||
sendMessageAPI,
|
||||
getConversationAPI,
|
||||
getMessagesAPI,
|
||||
sendAttachmentAPI,
|
||||
toggleTyping,
|
||||
setUserLastSeenAt,
|
||||
sendEmailTranscript,
|
||||
toggleStatus,
|
||||
setCustomAttributes,
|
||||
deleteCustomAttribute,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
const buildUrl = endPoint => `/api/v1/${endPoint}${window.location.search}`;
|
||||
|
||||
export default {
|
||||
create(label) {
|
||||
return API.post(buildUrl('widget/labels'), { label });
|
||||
},
|
||||
destroy(label) {
|
||||
return API.delete(buildUrl(`widget/labels/${label}`));
|
||||
},
|
||||
};
|
||||
120
research/chatwoot/app/javascript/widget/api/endPoints.js
Executable file
120
research/chatwoot/app/javascript/widget/api/endPoints.js
Executable file
@@ -0,0 +1,120 @@
|
||||
import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper';
|
||||
import { generateEventParams } from './events';
|
||||
|
||||
const createConversation = params => {
|
||||
const referrerURL = window.referrerURL || '';
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
return {
|
||||
url: `/api/v1/widget/conversations${search}`,
|
||||
params: {
|
||||
contact: {
|
||||
name: params.fullName,
|
||||
email: params.emailAddress,
|
||||
phone_number: params.phoneNumber,
|
||||
},
|
||||
message: {
|
||||
content: params.message,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
custom_attributes: params.customAttributes,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage = (content, replyTo) => {
|
||||
const referrerURL = window.referrerURL || '';
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
return {
|
||||
url: `/api/v1/widget/messages${search}`,
|
||||
params: {
|
||||
message: {
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||
const { referrerURL = '' } = window;
|
||||
const timestamp = new Date().toString();
|
||||
const { file } = attachment;
|
||||
|
||||
const formData = new FormData();
|
||||
if (typeof file === 'string') {
|
||||
formData.append('message[attachments][]', file);
|
||||
} else {
|
||||
formData.append('message[attachments][]', file, file.name);
|
||||
}
|
||||
|
||||
formData.append('message[referer_url]', referrerURL);
|
||||
formData.append('message[timestamp]', timestamp);
|
||||
if (replyTo !== null) {
|
||||
formData.append('message[reply_to]', replyTo);
|
||||
}
|
||||
return {
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: formData,
|
||||
};
|
||||
};
|
||||
|
||||
const getConversation = ({ before, after }) => ({
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: { before, after },
|
||||
});
|
||||
|
||||
const updateMessage = id => ({
|
||||
url: `/api/v1/widget/messages/${id}${window.location.search}`,
|
||||
});
|
||||
|
||||
const getAvailableAgents = token => ({
|
||||
url: '/api/v1/widget/inbox_members',
|
||||
params: {
|
||||
website_token: token,
|
||||
},
|
||||
});
|
||||
const getCampaigns = token => ({
|
||||
url: '/api/v1/widget/campaigns',
|
||||
params: {
|
||||
website_token: token,
|
||||
},
|
||||
});
|
||||
const triggerCampaign = ({ websiteToken, campaignId, customAttributes }) => ({
|
||||
url: '/api/v1/widget/events',
|
||||
data: {
|
||||
name: 'campaign.triggered',
|
||||
event_info: {
|
||||
campaign_id: campaignId,
|
||||
custom_attributes: customAttributes,
|
||||
...generateEventParams(),
|
||||
},
|
||||
},
|
||||
params: {
|
||||
website_token: websiteToken,
|
||||
},
|
||||
});
|
||||
|
||||
const getMostReadArticles = (slug, locale) => ({
|
||||
url: `/hc/${slug}/${locale}/articles.json`,
|
||||
params: {
|
||||
page: 1,
|
||||
sort: 'views',
|
||||
status: 1,
|
||||
per_page: 6,
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
createConversation,
|
||||
sendMessage,
|
||||
sendAttachment,
|
||||
getConversation,
|
||||
updateMessage,
|
||||
getAvailableAgents,
|
||||
getCampaigns,
|
||||
triggerCampaign,
|
||||
getMostReadArticles,
|
||||
};
|
||||
19
research/chatwoot/app/javascript/widget/api/events.js
Normal file
19
research/chatwoot/app/javascript/widget/api/events.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper';
|
||||
|
||||
export const generateEventParams = () => ({
|
||||
initiated_at: {
|
||||
timestamp: new Date().toString(),
|
||||
},
|
||||
referer: window.referrerURL || '',
|
||||
});
|
||||
|
||||
export default {
|
||||
create(name) {
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
return API.post(`/api/v1/widget/events${search}`, {
|
||||
name,
|
||||
event_info: generateEventParams(),
|
||||
});
|
||||
},
|
||||
};
|
||||
12
research/chatwoot/app/javascript/widget/api/integration.js
Normal file
12
research/chatwoot/app/javascript/widget/api/integration.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
import { buildSearchParamsWithLocale } from '../helpers/urlParamsHelper';
|
||||
|
||||
export default {
|
||||
addParticipantToDyteMeeting: messageId => {
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
const urlData = {
|
||||
url: `/api/v1/widget/integrations/dyte/add_participant_to_meeting${search}`,
|
||||
};
|
||||
return API.post(urlData.url, { message_id: messageId });
|
||||
},
|
||||
};
|
||||
12
research/chatwoot/app/javascript/widget/api/message.js
Executable file
12
research/chatwoot/app/javascript/widget/api/message.js
Executable file
@@ -0,0 +1,12 @@
|
||||
import authEndPoint from 'widget/api/endPoints';
|
||||
import { API } from 'widget/helpers/axios';
|
||||
|
||||
export default {
|
||||
update: ({ messageId, email, values }) => {
|
||||
const urlData = authEndPoint.updateMessage(messageId);
|
||||
return API.patch(urlData.url, {
|
||||
contact: { email },
|
||||
message: { submitted_values: values },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import endPoints from '../endPoints';
|
||||
|
||||
describe('#sendMessage', () => {
|
||||
it('returns correct payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: {
|
||||
$i18n: {
|
||||
locale: 'ar',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(endPoints.sendMessage('hello')).toEqual({
|
||||
url: `/api/v1/widget/messages?param=1&locale=ar`,
|
||||
params: {
|
||||
message: {
|
||||
content: 'hello',
|
||||
referer_url: '',
|
||||
timestamp: 'mock date',
|
||||
},
|
||||
},
|
||||
});
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getConversation', () => {
|
||||
it('returns correct payload', () => {
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '',
|
||||
});
|
||||
expect(endPoints.getConversation({ before: 123 })).toEqual({
|
||||
url: `/api/v1/widget/messages`,
|
||||
params: {
|
||||
before: 123,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#triggerCampaign', () => {
|
||||
it('should returns correct payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '',
|
||||
});
|
||||
const websiteToken = 'ADSDJ2323MSDSDFMMMASDM';
|
||||
const campaignId = 12;
|
||||
expect(
|
||||
endPoints.triggerCampaign({
|
||||
websiteToken,
|
||||
campaignId,
|
||||
})
|
||||
).toEqual({
|
||||
url: `/api/v1/widget/events`,
|
||||
data: {
|
||||
name: 'campaign.triggered',
|
||||
event_info: {
|
||||
campaign_id: campaignId,
|
||||
referer: '',
|
||||
initiated_at: {
|
||||
timestamp: 'mock date',
|
||||
},
|
||||
},
|
||||
},
|
||||
params: {
|
||||
website_token: websiteToken,
|
||||
},
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getConversation', () => {
|
||||
it('should returns correct payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '',
|
||||
});
|
||||
expect(
|
||||
endPoints.getConversation({
|
||||
after: 123,
|
||||
})
|
||||
).toEqual({
|
||||
url: `/api/v1/widget/messages`,
|
||||
params: {
|
||||
after: 123,
|
||||
before: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
BIN
research/chatwoot/app/javascript/widget/assets/images/defaultUser.png
Executable file
BIN
research/chatwoot/app/javascript/widget/assets/images/defaultUser.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512px" height="512px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 59.1 (86144) - https://sketch.com -->
|
||||
<title>woot-log</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="woot-log" fill-rule="nonzero">
|
||||
<circle id="Oval" fill="#47A7F6" cx="256" cy="256" r="256"></circle>
|
||||
<path d="M362.807947,368.807947 L244.122956,368.807947 C178.699407,368.807947 125.456954,315.561812 125.456954,250.12177 C125.456954,184.703089 178.699407,131.456954 244.124143,131.456954 C309.565494,131.456954 362.807947,184.703089 362.807947,250.12177 L362.807947,368.807947 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="6" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 916 B |
11
research/chatwoot/app/javascript/widget/assets/images/message-send.svg
Executable file
11
research/chatwoot/app/javascript/widget/assets/images/message-send.svg
Executable file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 59.1 (86144) - https://sketch.com -->
|
||||
<title>message-send</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="Logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="message-send" fill="#999A9B" fill-rule="nonzero">
|
||||
<path d="M18.34,7.32 L4.34,0.32 C3.20803579,-0.243393454 1.84434515,-0.0365739638 0.930331262,0.837115781 C0.0163173744,1.71080553 -0.251780361,3.06378375 0.26,4.22 L2.66,9.59 L2.66,9.59 C2.77000426,9.8522654 2.77000426,10.1477346 2.66,10.41 L0.26,15.78 C-0.153051509,16.7079201 -0.0685371519,17.7818234 0.48458191,18.6337075 C1.03770097,19.4855916 1.98429967,19.9997529 3,20 C3.46823099,19.9953274 3.9294892,19.8859921 4.35,19.68 L18.35,12.68 C19.3627539,12.1705304 20.001816,11.1336797 20.001816,10 C20.001816,8.86632027 19.3627539,7.82946961 18.35,7.32 L18.34,7.32 Z M17.45,10.89 L3.45,17.89 C3.07351737,18.0707705 2.62434212,17.9985396 2.32351279,17.7088521 C2.02268345,17.4191646 1.93356002,16.9730338 2.1,16.59 L4.49,11.22 C4.5209392,11.1482915 4.54765161,11.0748324 4.57,11 L11.46,11 C12.0122847,11 12.46,10.5522847 12.46,10 C12.46,9.44771525 12.0122847,9 11.46,9 L4.57,9 C4.54765161,8.9251676 4.5209392,8.85170847 4.49,8.78 L2.1,3.41 C1.93356002,3.02696622 2.02268345,2.5808354 2.32351279,2.2911479 C2.62434212,2.00146039 3.07351737,1.92922952 3.45,2.11 L17.45,9.11 C17.7839662,9.28109597 17.9940395,9.62475706 17.9940395,10 C17.9940395,10.3752429 17.7839662,10.718904 17.45,10.89 Z" id="Shape"></path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-paperclip"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
|
||||
|
After Width: | Height: | Size: 352 B |
BIN
research/chatwoot/app/javascript/widget/assets/images/send.png
Executable file
BIN
research/chatwoot/app/javascript/widget/assets/images/send.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
research/chatwoot/app/javascript/widget/assets/images/typing.gif
Normal file
BIN
research/chatwoot/app/javascript/widget/assets/images/typing.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
53
research/chatwoot/app/javascript/widget/assets/scss/_reset.scss
Executable file
53
research/chatwoot/app/javascript/widget/assets/scss/_reset.scss
Executable file
@@ -0,0 +1,53 @@
|
||||
// scss-lint:disable
|
||||
/* http://meyerweb.com/eric/tools/css/reset/
|
||||
v2.0 | 20110126
|
||||
License: none (public domain)
|
||||
*/
|
||||
|
||||
html, body, div, span, applet, object, iframe,
|
||||
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
|
||||
a, abbr, acronym, address, big, cite, code,
|
||||
del, dfn, em, img, ins, kbd, q, s, samp,
|
||||
small, strike, strong, sub, sup, tt, var,
|
||||
b, u, i, center,
|
||||
dl, dt, dd, ol, ul, li,
|
||||
fieldset, form, label, legend,
|
||||
table, caption, tbody, tfoot, thead, tr, th, td,
|
||||
article, aside, canvas, details, embed,
|
||||
figure, figcaption, footer, header, hgroup,
|
||||
menu, nav, output, ruby, section, summary,
|
||||
time, mark, audio, video {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
font-size: 100%;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/* HTML5 display-role reset for older browsers */
|
||||
article, aside, details, figcaption, figure,
|
||||
footer, header, hgroup, menu, nav, section {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
ol, ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
blockquote, q {
|
||||
quotes: none;
|
||||
}
|
||||
|
||||
blockquote:before, blockquote:after,
|
||||
q:before, q:after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
.file-uploads .attachment-button + label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.conversation-wrap {
|
||||
.agent-message {
|
||||
@apply items-end flex flex-row justify-start mt-0 ltr:mr-0 rtl:mr-2 mb-0.5 ltr:ml-2 rtl:ml-0 max-w-[88%];
|
||||
|
||||
.avatar-wrap {
|
||||
@apply flex-shrink-0 h-6 w-6;
|
||||
|
||||
.user-thumbnail-box {
|
||||
@apply -mt-8;
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrap {
|
||||
@apply flex-grow flex-shrink-0 ltr:ml-2 rtl:mr-2 max-w-[90%];
|
||||
}
|
||||
}
|
||||
|
||||
&.is-typing .messages-wrap div:last-child {
|
||||
.agent-message {
|
||||
.agent-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-thumbnail-box {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
@apply text-xs font-medium my-2 ltr:pl-0.5 rtl:pr-0.5;
|
||||
}
|
||||
|
||||
.has-attachment {
|
||||
overflow: hidden;
|
||||
|
||||
:not([audio]) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.has-text {
|
||||
@apply mt-1;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-message-wrap {
|
||||
+ .agent-message-wrap {
|
||||
@apply mt-0.5;
|
||||
|
||||
.agent-message .chat-bubble {
|
||||
@apply ltr:rounded-tl-[0.25rem] rtl:rounded-tr-[0.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
+ .user-message-wrap {
|
||||
@apply mt-4;
|
||||
}
|
||||
|
||||
&.has-response + .user-message-wrap {
|
||||
@apply mt-0.5;
|
||||
|
||||
.chat-bubble {
|
||||
@apply ltr:rounded-tr-[0.25rem] rtl:rounded-tl-[0.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
&.has-response + .agent-message-wrap {
|
||||
@apply mt-4;
|
||||
}
|
||||
}
|
||||
|
||||
.user-message {
|
||||
@apply flex items-end flex-row justify-end max-w-[85%] ltr:text-right rtl:text-left mt-0 ltr:ml-auto rtl:mr-auto ltr:mr-1 rtl:ml-1 mb-0.5;
|
||||
|
||||
.message-wrap {
|
||||
@apply max-w-full ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
|
||||
.in-progress {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.is-failed {
|
||||
@apply flex items-end flex-row-reverse;
|
||||
|
||||
.chat-bubble.user {
|
||||
@apply bg-n-ruby-9 dark:bg-n-ruby-9 #{!important};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user.has-attachment {
|
||||
.icon-wrap {
|
||||
@apply text-white;
|
||||
}
|
||||
|
||||
.download {
|
||||
@apply text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.user-message-wrap {
|
||||
+ .user-message-wrap {
|
||||
@apply mt-0.5;
|
||||
|
||||
.user-message .chat-bubble {
|
||||
@apply ltr:rounded-tr-[0.25rem] rtl:rounded-tl-[0.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
+ .agent-message-wrap {
|
||||
@apply mt-4;
|
||||
}
|
||||
}
|
||||
|
||||
p:not(:last-child) {
|
||||
@apply mb-4;
|
||||
}
|
||||
}
|
||||
|
||||
.unread-messages {
|
||||
@apply flex flex-col flex-nowrap mt-0 overflow-y-auto w-full pb-2;
|
||||
|
||||
.chat-bubble-wrap {
|
||||
@apply mb-1;
|
||||
|
||||
&:first-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
@apply border border-solid border-n-slate-5 dark:border-n-slate-11/50 text-n-black;
|
||||
}
|
||||
|
||||
+ .chat-bubble-wrap {
|
||||
.chat-bubble {
|
||||
@apply ltr:rounded-tl-[0.25rem] rtl:rounded-tr-[0.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child .chat-bubble {
|
||||
@apply ltr:rounded-bl-[1.25rem] rtl:rounded-br-[1.25rem];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-widget-right .unread-wrap {
|
||||
@apply ltr:text-right rtl:text-left overflow-hidden;
|
||||
|
||||
.chat-bubble-wrap {
|
||||
.chat-bubble {
|
||||
@apply ltr:rounded-br-[0.25rem] rtl:rounded-bl-[0.25rem] rounded-[1.25rem];
|
||||
}
|
||||
|
||||
+ .chat-bubble-wrap {
|
||||
.chat-bubble {
|
||||
@apply ltr:rounded-tr-[0.25rem] rtl:rounded-tl-[0.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child .chat-bubble {
|
||||
@apply ltr:rounded-br-[1.25rem] rtl:rounded-bl-[1.25rem];
|
||||
}
|
||||
}
|
||||
|
||||
.close-unread-wrap {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
@apply shadow-[0_0.25rem_6px_rgba(50,50,93,0.08),0_1px_3px_rgba(0,0,0,0.05)] rounded-[1.25rem] inline-block text-sm leading-[1.5] max-w-full ltr:text-left rtl:text-right py-3 px-4 text-white;
|
||||
|
||||
word-break: break-word;
|
||||
|
||||
:not([audio]) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
> a {
|
||||
@apply text-n-brand;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.user {
|
||||
@apply ltr:rounded-br-[0.25rem] rtl:rounded-bl-[0.25rem];
|
||||
|
||||
> a {
|
||||
@apply text-white;
|
||||
}
|
||||
}
|
||||
|
||||
&.agent {
|
||||
@apply ltr:rounded-bl-[0.25rem] rtl:rounded-br-[0.25rem] text-n-slate-12;
|
||||
|
||||
.link {
|
||||
@apply text-n-brand;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
}
|
||||
382
research/chatwoot/app/javascript/widget/assets/scss/woot.scss
Executable file
382
research/chatwoot/app/javascript/widget/assets/scss/woot.scss
Executable file
@@ -0,0 +1,382 @@
|
||||
@import 'reset';
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import 'shared/assets/fonts/widget_fonts';
|
||||
@import 'views/conversation';
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
display: block;
|
||||
|
||||
.actions {
|
||||
.close-button {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.new-window--button {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-bubble-hidden {
|
||||
.actions {
|
||||
.close-button {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
ul {
|
||||
list-style: disc;
|
||||
@apply ltr:pl-3 rtl:pr-3;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style: decimal;
|
||||
@apply ltr:pl-4 rtl:pr-4;
|
||||
}
|
||||
}
|
||||
|
||||
.is-flat-design {
|
||||
.chat-bubble {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
input {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-message--input {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&.is-focused {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
@apply block font-medium py-1 px-0 capitalize;
|
||||
}
|
||||
|
||||
input:not(.reset-base),
|
||||
textarea:not(.reset-base) {
|
||||
font-family: inherit;
|
||||
@apply rounded-lg box-border bg-n-background dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] outline-n-weak block text-base leading-[1.5] p-2.5 w-full text-n-slate-12 focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
|
||||
|
||||
&:disabled {
|
||||
@apply opacity-40 cursor-not-allowed;
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
@apply text-ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
select:not(.reset-base) {
|
||||
@apply bg-n-background dark:bg-n-alpha-2 w-full p-2.5 border-none outline outline-1 outline-offset-[-1px] outline-n-weak rounded-lg text-n-slate-12 text-base ltr:pr-10 rtl:pl-10 font-normal ltr:bg-[right_-1.6rem_center] rtl:bg-[left_-1.6rem_center] focus:outline-n-brand focus:ring-1 focus:ring-n-brand;
|
||||
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
|
||||
background-origin: content-box;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 9px 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
p code {
|
||||
@apply bg-n-slate-3 dark:bg-n-alpha-2 text-n-slate-11 text-sm inline-block rounded py-px px-1;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-n-slate-3 dark:bg-n-alpha-2 text-n-slate-11 overflow-y-auto rounded-md p-2 mt-1 mb-2 block leading-[1.5] whitespace-pre-wrap;
|
||||
|
||||
code {
|
||||
@apply bg-transparent text-n-slate-11 p-0 text-sm;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply ltr:border-l-4 rtl:border-r-4 border-n-slate-3 dark:border-n-alpha-2 border-solid my-1 px-0 text-n-slate-11 py-1 ltr:pr-2 rtl:pr-4 ltr:pl-4 rtl:pl-2;
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply appearance-none bg-n-brand border border-solid border-n-brand text-white cursor-pointer inline-block text-sm h-10 leading-none outline-none outline-0 py-1 px-4 text-center no-underline select-none align-middle whitespace-nowrap;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
@apply no-underline border-n-brand brightness-110;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
@apply no-underline border-n-brand brightness-125;
|
||||
}
|
||||
|
||||
&[disabled],
|
||||
&:disabled,
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.small {
|
||||
@apply text-xs h-6 py-1 px-3;
|
||||
}
|
||||
|
||||
&.large {
|
||||
@apply text-base h-12 py-2 px-6;
|
||||
}
|
||||
|
||||
&.block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.transparent {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// scss-lint:disable PropertySortOrder
|
||||
@layer base {
|
||||
// NEXT COLORS START
|
||||
:root {
|
||||
// slate
|
||||
--slate-1: 252 252 253;
|
||||
--slate-2: 249 249 251;
|
||||
--slate-3: 240 240 243;
|
||||
--slate-4: 232 232 236;
|
||||
--slate-5: 224 225 230;
|
||||
--slate-6: 217 217 224;
|
||||
--slate-7: 205 206 214;
|
||||
--slate-8: 185 187 198;
|
||||
--slate-9: 139 141 152;
|
||||
--slate-10: 128 131 141;
|
||||
--slate-11: 96 100 108;
|
||||
--slate-12: 28 32 36;
|
||||
|
||||
// iris
|
||||
--iris-1: 253 253 255;
|
||||
--iris-2: 248 248 255;
|
||||
--iris-3: 240 241 254;
|
||||
--iris-4: 230 231 255;
|
||||
--iris-5: 218 220 255;
|
||||
--iris-6: 203 205 255;
|
||||
--iris-7: 184 186 248;
|
||||
--iris-8: 155 158 240;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 81 81 205;
|
||||
--iris-11: 87 83 198;
|
||||
--iris-12: 39 41 98;
|
||||
|
||||
// ruby
|
||||
--ruby-1: 255 252 253;
|
||||
--ruby-2: 255 247 248;
|
||||
--ruby-3: 254 234 237;
|
||||
--ruby-4: 255 220 225;
|
||||
--ruby-5: 255 206 214;
|
||||
--ruby-6: 248 191 200;
|
||||
--ruby-7: 239 172 184;
|
||||
--ruby-8: 229 146 163;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 220 59 93;
|
||||
--ruby-11: 202 36 77;
|
||||
--ruby-12: 100 23 43;
|
||||
|
||||
// amber
|
||||
--amber-1: 254 253 251;
|
||||
--amber-2: 254 251 233;
|
||||
--amber-3: 255 247 194;
|
||||
--amber-4: 255 238 156;
|
||||
--amber-5: 251 229 119;
|
||||
--amber-6: 243 214 115;
|
||||
--amber-7: 233 193 98;
|
||||
--amber-8: 226 163 54;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 186 24;
|
||||
--amber-11: 171 100 0;
|
||||
--amber-12: 79 52 34;
|
||||
|
||||
// teal
|
||||
--teal-1: 250 254 253;
|
||||
--teal-2: 243 251 249;
|
||||
--teal-3: 224 248 243;
|
||||
--teal-4: 204 243 234;
|
||||
--teal-5: 184 234 224;
|
||||
--teal-6: 161 222 210;
|
||||
--teal-7: 131 205 193;
|
||||
--teal-8: 83 185 171;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 13 155 138;
|
||||
--teal-11: 0 133 115;
|
||||
--teal-12: 13 61 56;
|
||||
|
||||
// gray
|
||||
--gray-1: 252 252 252;
|
||||
--gray-2: 249 249 249;
|
||||
--gray-3: 240 240 240;
|
||||
--gray-4: 232 232 232;
|
||||
--gray-5: 224 224 224;
|
||||
--gray-6: 217 217 217;
|
||||
--gray-7: 206 206 206;
|
||||
--gray-8: 187 187 187;
|
||||
--gray-9: 141 141 141;
|
||||
--gray-10: 131 131 131;
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-iris: 230 231 255;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--white-alpha: 255, 255, 255, 0.8;
|
||||
}
|
||||
|
||||
.dark {
|
||||
// slate
|
||||
--slate-1: 17 17 19;
|
||||
--slate-2: 24 25 27;
|
||||
--slate-3: 33 34 37;
|
||||
--slate-4: 39 42 45;
|
||||
--slate-5: 46 49 53;
|
||||
--slate-6: 54 58 63;
|
||||
--slate-7: 67 72 78;
|
||||
--slate-8: 90 97 105;
|
||||
--slate-9: 105 110 119;
|
||||
--slate-10: 119 123 132;
|
||||
--slate-11: 176 180 186;
|
||||
--slate-12: 237 238 240;
|
||||
|
||||
// iris
|
||||
--iris-1: 19 19 30;
|
||||
--iris-2: 23 22 37;
|
||||
--iris-3: 32 34 72;
|
||||
--iris-4: 38 42 101;
|
||||
--iris-5: 48 51 116;
|
||||
--iris-6: 61 62 130;
|
||||
--iris-7: 74 74 149;
|
||||
--iris-8: 89 88 177;
|
||||
--iris-9: 91 91 214;
|
||||
--iris-10: 84 114 228;
|
||||
--iris-11: 158 177 255;
|
||||
--iris-12: 224 223 254;
|
||||
|
||||
// ruby
|
||||
--ruby-1: 25 17 19;
|
||||
--ruby-2: 30 21 23;
|
||||
--ruby-3: 58 20 30;
|
||||
--ruby-4: 78 19 37;
|
||||
--ruby-5: 94 26 46;
|
||||
--ruby-6: 111 37 57;
|
||||
--ruby-7: 136 52 71;
|
||||
--ruby-8: 179 68 90;
|
||||
--ruby-9: 229 70 102;
|
||||
--ruby-10: 236 90 114;
|
||||
--ruby-11: 255 148 157;
|
||||
--ruby-12: 254 210 225;
|
||||
|
||||
// amber
|
||||
--amber-1: 22 18 12;
|
||||
--amber-2: 29 24 15;
|
||||
--amber-3: 48 32 8;
|
||||
--amber-4: 63 39 0;
|
||||
--amber-5: 77 48 0;
|
||||
--amber-6: 92 61 5;
|
||||
--amber-7: 113 79 25;
|
||||
--amber-8: 143 100 36;
|
||||
--amber-9: 255 197 61;
|
||||
--amber-10: 255 214 10;
|
||||
--amber-11: 255 202 22;
|
||||
--amber-12: 255 231 179;
|
||||
|
||||
// teal
|
||||
--teal-1: 13 21 20;
|
||||
--teal-2: 17 28 27;
|
||||
--teal-3: 13 45 42;
|
||||
--teal-4: 2 59 55;
|
||||
--teal-5: 8 72 67;
|
||||
--teal-6: 20 87 80;
|
||||
--teal-7: 28 105 97;
|
||||
--teal-8: 32 126 115;
|
||||
--teal-9: 18 165 148;
|
||||
--teal-10: 14 179 158;
|
||||
--teal-11: 11 216 182;
|
||||
--teal-12: 173 240 221;
|
||||
|
||||
// gray
|
||||
--gray-1: 17 17 17;
|
||||
--gray-2: 25 25 25;
|
||||
--gray-3: 34 34 34;
|
||||
--gray-4: 42 42 42;
|
||||
--gray-5: 49 49 49;
|
||||
--gray-6: 58 58 58;
|
||||
--gray-7: 72 72 72;
|
||||
--gray-8: 96 96 96;
|
||||
--gray-9: 110 110 110;
|
||||
--gray-10: 123 123 123;
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
}
|
||||
258
research/chatwoot/app/javascript/widget/components/AgentMessage.vue
Executable file
258
research/chatwoot/app/javascript/widget/components/AgentMessage.vue
Executable file
@@ -0,0 +1,258 @@
|
||||
<script>
|
||||
import UserMessage from 'widget/components/UserMessage.vue';
|
||||
import AgentMessageBubble from 'widget/components/AgentMessageBubble.vue';
|
||||
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import ImageBubble from 'widget/components/ImageBubble.vue';
|
||||
import VideoBubble from 'widget/components/VideoBubble.vue';
|
||||
import FileBubble from 'widget/components/FileBubble.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import messageMixin from '../mixins/messageMixin';
|
||||
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessage',
|
||||
components: {
|
||||
AgentMessageBubble,
|
||||
ImageBubble,
|
||||
VideoBubble,
|
||||
Avatar,
|
||||
UserMessage,
|
||||
FileBubble,
|
||||
MessageReplyButton,
|
||||
ReplyToChip,
|
||||
},
|
||||
mixins: [configMixin, messageMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
replyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageError: false,
|
||||
hasVideoError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
shouldDisplayAgentMessage() {
|
||||
if (
|
||||
this.contentType === 'input_select' &&
|
||||
this.messageContentAttributes.submitted_values &&
|
||||
!this.message.content
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return this.message.content;
|
||||
},
|
||||
readableTime() {
|
||||
const { created_at: createdAt = '' } = this.message;
|
||||
return messageStamp(createdAt, 'LLL d yyyy, h:mm a');
|
||||
},
|
||||
messageType() {
|
||||
const { message_type: type = 1 } = this.message;
|
||||
return type;
|
||||
},
|
||||
contentType() {
|
||||
const { content_type: type = '' } = this.message;
|
||||
return type;
|
||||
},
|
||||
agentName() {
|
||||
if (this.message.sender) {
|
||||
return this.message.sender.available_name || this.message.sender.name;
|
||||
}
|
||||
|
||||
if (this.useInboxAvatarForBot) {
|
||||
return this.channelConfig.websiteName;
|
||||
}
|
||||
|
||||
return this.$t('UNREAD_VIEW.BOT');
|
||||
},
|
||||
avatarUrl() {
|
||||
const displayImage = this.useInboxAvatarForBot
|
||||
? this.inboxAvatarUrl
|
||||
: '/assets/images/chatwoot_bot.png';
|
||||
|
||||
if (this.message.message_type === MESSAGE_TYPE.TEMPLATE) {
|
||||
return displayImage;
|
||||
}
|
||||
|
||||
return this.message.sender
|
||||
? this.message.sender.avatar_url
|
||||
: displayImage;
|
||||
},
|
||||
hasRecordedResponse() {
|
||||
return (
|
||||
this.messageContentAttributes.submitted_email ||
|
||||
(this.messageContentAttributes.submitted_values &&
|
||||
!['form', 'input_csat'].includes(this.contentType))
|
||||
);
|
||||
},
|
||||
responseMessage() {
|
||||
if (this.messageContentAttributes.submitted_email) {
|
||||
return { content: this.messageContentAttributes.submitted_email };
|
||||
}
|
||||
|
||||
if (this.messageContentAttributes.submitted_values) {
|
||||
if (this.contentType === 'input_select') {
|
||||
const [selectionOption = {}] =
|
||||
this.messageContentAttributes.submitted_values;
|
||||
return { content: selectionOption.title || selectionOption.value };
|
||||
}
|
||||
}
|
||||
return '';
|
||||
},
|
||||
isASubmittedForm() {
|
||||
return isASubmittedFormMessage(this.message);
|
||||
},
|
||||
submittedFormValues() {
|
||||
return this.messageContentAttributes.submitted_values.map(
|
||||
submittedValue => ({
|
||||
id: submittedValue.name,
|
||||
content: submittedValue.value,
|
||||
})
|
||||
);
|
||||
},
|
||||
wrapClass() {
|
||||
return {
|
||||
'has-text': this.shouldDisplayAgentMessage,
|
||||
};
|
||||
},
|
||||
hasReplyTo() {
|
||||
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
message() {
|
||||
this.hasImageError = false;
|
||||
this.hasVideoError = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.hasImageError = false;
|
||||
this.hasVideoError = false;
|
||||
},
|
||||
methods: {
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
onVideoLoadError() {
|
||||
this.hasVideoError = true;
|
||||
},
|
||||
toggleReply() {
|
||||
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="agent-message-wrap group"
|
||||
:class="{
|
||||
'has-response': hasRecordedResponse || isASubmittedForm,
|
||||
}"
|
||||
>
|
||||
<div v-if="!isASubmittedForm" class="agent-message">
|
||||
<div class="avatar-wrap">
|
||||
<div class="user-thumbnail-box">
|
||||
<Avatar
|
||||
v-if="message.showAvatar || hasRecordedResponse"
|
||||
:src="avatarUrl"
|
||||
:size="24"
|
||||
:name="agentName"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-wrap">
|
||||
<div v-if="hasReplyTo" class="flex mt-2 mb-1 text-xs">
|
||||
<ReplyToChip :reply-to="replyTo" />
|
||||
</div>
|
||||
<div class="flex w-full gap-1">
|
||||
<div
|
||||
class="space-y-2"
|
||||
:class="{
|
||||
'w-full':
|
||||
contentType === 'form' &&
|
||||
!messageContentAttributes?.submitted_values,
|
||||
}"
|
||||
>
|
||||
<AgentMessageBubble
|
||||
v-if="shouldDisplayAgentMessage"
|
||||
:content-type="contentType"
|
||||
:message-content-attributes="messageContentAttributes"
|
||||
:message-id="message.id"
|
||||
:message-type="messageType"
|
||||
:message="message.content"
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="space-y-2 chat-bubble has-attachment agent bg-n-background dark:bg-n-solid-3"
|
||||
:class="wrapClass"
|
||||
>
|
||||
<div
|
||||
v-for="attachment in message.attachments"
|
||||
:key="attachment.id"
|
||||
>
|
||||
<ImageBubble
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:thumb="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
|
||||
<VideoBubble
|
||||
v-if="attachment.file_type === 'video' && !hasVideoError"
|
||||
:url="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onVideoLoadError"
|
||||
/>
|
||||
|
||||
<audio
|
||||
v-else-if="attachment.file_type === 'audio'"
|
||||
controls
|
||||
class="h-10 dark:invert"
|
||||
>
|
||||
<source :src="attachment.data_url" />
|
||||
</audio>
|
||||
<FileBubble v-else :url="attachment.data_url" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-end">
|
||||
<MessageReplyButton
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@click="toggleReply"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="message.showAvatar || hasRecordedResponse"
|
||||
v-dompurify-html="agentName"
|
||||
class="agent-name text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserMessage v-if="hasRecordedResponse" :message="responseMessage" />
|
||||
<div v-if="isASubmittedForm">
|
||||
<UserMessage
|
||||
v-for="submittedValue in submittedFormValues"
|
||||
:key="submittedValue.id"
|
||||
:message="submittedValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
152
research/chatwoot/app/javascript/widget/components/AgentMessageBubble.vue
Executable file
152
research/chatwoot/app/javascript/widget/components/AgentMessageBubble.vue
Executable file
@@ -0,0 +1,152 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import ChatCard from 'shared/components/ChatCard.vue';
|
||||
import ChatForm from 'shared/components/ChatForm.vue';
|
||||
import ChatOptions from 'shared/components/ChatOptions.vue';
|
||||
import ChatArticle from './template/Article.vue';
|
||||
import EmailInput from './template/EmailInput.vue';
|
||||
import CustomerSatisfaction from 'shared/components/CustomerSatisfaction.vue';
|
||||
import IntegrationCard from './template/IntegrationCard.vue';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessageBubble',
|
||||
components: {
|
||||
ChatArticle,
|
||||
ChatCard,
|
||||
ChatForm,
|
||||
ChatOptions,
|
||||
EmailInput,
|
||||
CustomerSatisfaction,
|
||||
IntegrationCard,
|
||||
},
|
||||
props: {
|
||||
message: { type: String, default: null },
|
||||
contentType: { type: String, default: null },
|
||||
messageType: { type: Number, default: null },
|
||||
messageId: { type: Number, default: null },
|
||||
messageContentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
|
||||
useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
getPlainText,
|
||||
truncateMessage,
|
||||
highlightContent,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isTemplate() {
|
||||
return this.messageType === 3;
|
||||
},
|
||||
isTemplateEmail() {
|
||||
return this.contentType === 'input_email';
|
||||
},
|
||||
isCards() {
|
||||
return this.contentType === 'cards';
|
||||
},
|
||||
isOptions() {
|
||||
return this.contentType === 'input_select';
|
||||
},
|
||||
isForm() {
|
||||
return this.contentType === 'form';
|
||||
},
|
||||
isArticle() {
|
||||
return this.contentType === 'article';
|
||||
},
|
||||
isCSAT() {
|
||||
return this.contentType === 'input_csat';
|
||||
},
|
||||
isIntegrations() {
|
||||
return this.contentType === 'integrations';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onResponse(messageResponse) {
|
||||
this.$store.dispatch('message/update', messageResponse);
|
||||
},
|
||||
onOptionSelect(selectedOption) {
|
||||
this.onResponse({
|
||||
submittedValues: [selectedOption],
|
||||
messageId: this.messageId,
|
||||
});
|
||||
},
|
||||
onFormSubmit(formValues) {
|
||||
const formValuesAsArray = Object.keys(formValues).map(key => ({
|
||||
name: key,
|
||||
value: formValues[key],
|
||||
}));
|
||||
this.onResponse({
|
||||
submittedValues: formValuesAsArray,
|
||||
messageId: this.messageId,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-bubble-wrap">
|
||||
<div
|
||||
v-if="
|
||||
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
|
||||
"
|
||||
class="chat-bubble agent bg-n-background dark:bg-n-solid-3 text-n-slate-12"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message, false)"
|
||||
class="message-content text-n-slate-12"
|
||||
/>
|
||||
<EmailInput
|
||||
v-if="isTemplateEmail"
|
||||
:message-id="messageId"
|
||||
:message-content-attributes="messageContentAttributes"
|
||||
/>
|
||||
|
||||
<IntegrationCard
|
||||
v-if="isIntegrations"
|
||||
:message-id="messageId"
|
||||
:meeting-data="messageContentAttributes.data"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isOptions">
|
||||
<ChatOptions
|
||||
:title="message"
|
||||
:options="messageContentAttributes.items"
|
||||
:hide-fields="!!messageContentAttributes.submitted_values"
|
||||
@option-select="onOptionSelect"
|
||||
/>
|
||||
</div>
|
||||
<ChatForm
|
||||
v-if="isForm && !messageContentAttributes.submitted_values"
|
||||
:items="messageContentAttributes.items"
|
||||
:button-label="messageContentAttributes.button_label"
|
||||
:submitted-values="messageContentAttributes.submitted_values"
|
||||
@submit="onFormSubmit"
|
||||
/>
|
||||
<div v-if="isCards">
|
||||
<ChatCard
|
||||
v-for="item in messageContentAttributes.items"
|
||||
:key="item.title"
|
||||
:media-url="item.media_url"
|
||||
:title="item.title"
|
||||
:description="item.description"
|
||||
:actions="item.actions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isArticle">
|
||||
<ChatArticle :items="messageContentAttributes.items" />
|
||||
</div>
|
||||
<CustomerSatisfaction
|
||||
v-if="isCSAT"
|
||||
:message-content-attributes="messageContentAttributes.submitted_values"
|
||||
:display-type="messageContentAttributes.display_type"
|
||||
:message="message"
|
||||
:message-id="messageId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'AgentTypingBubble',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agent-message-wrap sticky bottom-1">
|
||||
<div class="agent-message">
|
||||
<div class="avatar-wrap" />
|
||||
<div class="message-wrap mt-2">
|
||||
<div
|
||||
class="chat-bubble agent typing-bubble bg-n-background dark:bg-n-solid-3"
|
||||
>
|
||||
<img
|
||||
src="assets/images/typing.gif"
|
||||
alt="Agent is typing a message"
|
||||
class="!w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.typing-bubble {
|
||||
@apply max-w-[2.4rem] p-2 ltr:rounded-bl-[1.25rem] rtl:rounded-br-[1.25rem] ltr:rounded-tl-lg rtl:rounded-tr-lg;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import GroupedAvatars from 'widget/components/GroupedAvatars.vue';
|
||||
import AvailabilityText from './AvailabilityText.vue';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
|
||||
const props = defineProps({
|
||||
agents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAvatars: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
textClasses: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const availableMessage = useMapGetter('appConfig/getAvailableMessage');
|
||||
const unavailableMessage = useMapGetter('appConfig/getUnavailableMessage');
|
||||
|
||||
// Pass toRef(props, 'agents') instead of props.agents to maintain reactivity
|
||||
// when the parent component's agents prop updates (e.g., after API response)
|
||||
const {
|
||||
currentTime,
|
||||
hasOnlineAgents,
|
||||
isOnline,
|
||||
inboxConfig,
|
||||
isInWorkingHours,
|
||||
} = useAvailability(toRef(props, 'agents'));
|
||||
|
||||
const workingHours = computed(() => inboxConfig.value.workingHours || []);
|
||||
const workingHoursEnabled = computed(
|
||||
() => inboxConfig.value.workingHoursEnabled || false
|
||||
);
|
||||
const utcOffset = computed(
|
||||
() => inboxConfig.value.utcOffset || inboxConfig.value.timezone || 'UTC'
|
||||
);
|
||||
const replyTime = computed(
|
||||
() => inboxConfig.value.replyTime || 'in_a_few_minutes'
|
||||
);
|
||||
|
||||
// If online or in working hours
|
||||
const isAvailable = computed(
|
||||
() => isOnline.value || (workingHoursEnabled.value && isInWorkingHours.value)
|
||||
);
|
||||
|
||||
const headerText = computed(() =>
|
||||
isAvailable.value
|
||||
? availableMessage.value || t('TEAM_AVAILABILITY.ONLINE')
|
||||
: unavailableMessage.value || t('TEAM_AVAILABILITY.OFFLINE')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div v-if="showHeader" class="font-medium text-n-slate-12">
|
||||
{{ headerText }}
|
||||
</div>
|
||||
|
||||
<AvailabilityText
|
||||
:time="currentTime"
|
||||
:utc-offset="utcOffset"
|
||||
:working-hours="workingHours"
|
||||
:working-hours-enabled="workingHoursEnabled"
|
||||
:has-online-agents="hasOnlineAgents"
|
||||
:reply-time="replyTime"
|
||||
:is-online="isOnline"
|
||||
:is-in-working-hours="isInWorkingHours"
|
||||
:class="textClasses"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GroupedAvatars v-if="showAvatars && isOnline" :users="agents" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script setup>
|
||||
import AvailabilityText from './AvailabilityText.vue';
|
||||
|
||||
// Base time for consistent testing: Monday, July 15, 2024, 10:00:00 UTC
|
||||
const baseTime = new Date('2024-07-15T10:00:00.000Z');
|
||||
const utcOffset = '+00:00'; // UTC
|
||||
|
||||
const defaultProps = {
|
||||
time: baseTime,
|
||||
utcOffset,
|
||||
workingHours: [
|
||||
{
|
||||
dayOfWeek: 0,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Sunday
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Monday (current day)
|
||||
{
|
||||
dayOfWeek: 2,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Tuesday
|
||||
{
|
||||
dayOfWeek: 3,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Wednesday
|
||||
{
|
||||
dayOfWeek: 4,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Thursday
|
||||
{
|
||||
dayOfWeek: 5,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: false,
|
||||
}, // Friday
|
||||
{
|
||||
dayOfWeek: 6,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
closedAllDay: true,
|
||||
}, // Saturday (closed)
|
||||
],
|
||||
workingHoursEnabled: true,
|
||||
replyTime: 'in_a_few_minutes',
|
||||
isOnline: true,
|
||||
isInWorkingHours: true,
|
||||
};
|
||||
|
||||
const createVariant = (
|
||||
title,
|
||||
propsOverride = {},
|
||||
isOnlineOverride = null,
|
||||
isInWorkingHoursOverride = null
|
||||
) => {
|
||||
const props = { ...defaultProps, ...propsOverride };
|
||||
if (isOnlineOverride !== null) props.isOnline = isOnlineOverride;
|
||||
if (isInWorkingHoursOverride !== null)
|
||||
props.isInWorkingHours = isInWorkingHoursOverride;
|
||||
|
||||
// Adjust time for specific scenarios
|
||||
if (title.includes('Back Tomorrow')) {
|
||||
// Set time to just after closing on Monday to trigger 'Back Tomorrow' (Tuesday)
|
||||
props.time = new Date('2024-07-15T17:01:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Multiple Days Away')) {
|
||||
// Set time to Friday evening to trigger 'Back on Sunday' (as Saturday is closed)
|
||||
props.time = new Date('2024-07-19T18:00:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Same Day - In Minutes')) {
|
||||
// Monday 16:50, next slot is 17:00 (in 10 minutes)
|
||||
// To make this specific, let's assume the next slot is within the hour
|
||||
// For this, we need to be outside working hours but a slot is available soon.
|
||||
// Let's say current time is 8:50 AM, office opens at 9:00 AM.
|
||||
props.time = new Date('2024-07-15T08:50:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Same Day - In Hours')) {
|
||||
// Monday 07:30 AM, office opens at 9:00 AM (in 1.5 hours, rounds to 2 hours)
|
||||
props.time = new Date('2024-07-15T07:30:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('in 1 hour')) {
|
||||
// Monday 08:00 AM, office opens at 9:00 AM (exactly in 1 hour)
|
||||
// At exactly 1 hour difference, remainingMinutes = 0
|
||||
props.time = new Date('2024-07-15T08:00:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
if (title.includes('Back Same Day - At Time')) {
|
||||
// Monday 05:00 AM, office opens at 9:00 AM (at 9:00 AM)
|
||||
props.time = new Date('2024-07-15T05:00:00.000Z');
|
||||
props.isInWorkingHours = false;
|
||||
props.isOnline = false;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
props,
|
||||
};
|
||||
};
|
||||
|
||||
const variants = [
|
||||
createVariant(
|
||||
'Working Hours Disabled - Online',
|
||||
{ workingHoursEnabled: false },
|
||||
true,
|
||||
true
|
||||
),
|
||||
createVariant(
|
||||
'Working Hours Disabled - Offline',
|
||||
{ workingHoursEnabled: false },
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'All Day Closed - Offline',
|
||||
{
|
||||
workingHours: defaultProps.workingHours.map(wh => ({
|
||||
...wh,
|
||||
closedAllDay: true,
|
||||
})),
|
||||
},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant('Online and In Working Hours', {}, true, true),
|
||||
createVariant(
|
||||
'No Next Slot Available (e.g., all future slots closed or empty workingHours)',
|
||||
{ workingHours: [] }, // No working hours defined
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Tomorrow',
|
||||
{},
|
||||
false,
|
||||
false // Time will be adjusted by createVariant
|
||||
),
|
||||
createVariant('Back Multiple Days Away (e.g., on Sunday)', {}, false, false),
|
||||
createVariant(
|
||||
'Back Same Day - In Minutes (e.g., in 10 minutes)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Same Day - In Hours (e.g., in 2 hours)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Same Day - Exactly an Hour (e.g., in 1 hour)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
createVariant(
|
||||
'Back Same Day - At Time (e.g., at 09:00 AM)',
|
||||
{},
|
||||
false,
|
||||
false
|
||||
),
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Widget/Components/Availability/AvailabilityText"
|
||||
:layout="{ type: 'grid', width: 300 }"
|
||||
>
|
||||
<Variant v-for="(variant, i) in variants" :key="i" :title="variant.title">
|
||||
<AvailabilityText
|
||||
:time="variant.props.time"
|
||||
:utc-offset="variant.props.utcOffset"
|
||||
:working-hours="variant.props.workingHours"
|
||||
:working-hours-enabled="variant.props.workingHoursEnabled"
|
||||
:reply-time="variant.props.replyTime"
|
||||
:is-online="variant.props.isOnline"
|
||||
:is-in-working-hours="variant.props.isInWorkingHours"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getTime } from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
|
||||
import { findNextAvailableSlotDetails } from 'widget/helpers/availabilityHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
time: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
utcOffset: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
workingHours: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
workingHoursEnabled: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
replyTime: {
|
||||
type: String,
|
||||
default: 'in_a_few_minutes',
|
||||
},
|
||||
isOnline: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isInWorkingHours: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const MINUTE_ROUNDING_INTERVAL = 5;
|
||||
const HOUR_THRESHOLD_FOR_EXACT_TIME = 3;
|
||||
const MINUTES_IN_HOUR = 60;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dayNames = computed(() => [
|
||||
t('DAY_NAMES.SUNDAY'),
|
||||
t('DAY_NAMES.MONDAY'),
|
||||
t('DAY_NAMES.TUESDAY'),
|
||||
t('DAY_NAMES.WEDNESDAY'),
|
||||
t('DAY_NAMES.THURSDAY'),
|
||||
t('DAY_NAMES.FRIDAY'),
|
||||
t('DAY_NAMES.SATURDAY'),
|
||||
]);
|
||||
|
||||
// Check if all days in working hours are closed
|
||||
const allDayClosed = computed(() => {
|
||||
if (!props.workingHours.length) return false;
|
||||
return props.workingHours.every(slot => slot.closedAllDay);
|
||||
});
|
||||
|
||||
const replyTimeMessage = computed(() => {
|
||||
const replyTimeKey = `REPLY_TIME.${props.replyTime.toUpperCase()}`;
|
||||
return t(replyTimeKey);
|
||||
});
|
||||
|
||||
const nextSlot = computed(() => {
|
||||
if (
|
||||
!props.workingHoursEnabled ||
|
||||
allDayClosed.value ||
|
||||
(props.isInWorkingHours && props.isOnline)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slot = findNextAvailableSlotDetails(
|
||||
props.time,
|
||||
props.utcOffset,
|
||||
props.workingHours
|
||||
);
|
||||
if (!slot) return null;
|
||||
|
||||
return {
|
||||
...slot,
|
||||
hoursUntilOpen: Math.floor(slot.minutesUntilOpen / MINUTES_IN_HOUR),
|
||||
remainingMinutes: slot.minutesUntilOpen % MINUTES_IN_HOUR,
|
||||
};
|
||||
});
|
||||
|
||||
const roundedMinutesUntilOpen = computed(() => {
|
||||
if (!nextSlot.value) return 0;
|
||||
return (
|
||||
Math.ceil(nextSlot.value.remainingMinutes / MINUTE_ROUNDING_INTERVAL) *
|
||||
MINUTE_ROUNDING_INTERVAL
|
||||
);
|
||||
});
|
||||
|
||||
const adjustedHoursUntilOpen = computed(() => {
|
||||
if (!nextSlot.value) return 0;
|
||||
return nextSlot.value.remainingMinutes > 0
|
||||
? nextSlot.value.hoursUntilOpen + 1
|
||||
: nextSlot.value.hoursUntilOpen;
|
||||
});
|
||||
|
||||
const formattedOpeningTime = computed(() => {
|
||||
if (!nextSlot.value) return '';
|
||||
return getTime(
|
||||
nextSlot.value.config.openHour || 0,
|
||||
nextSlot.value.config.openMinutes || 0
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span>
|
||||
<!-- 1. If currently in working hours, show reply time -->
|
||||
<template v-if="isInWorkingHours">
|
||||
{{ replyTimeMessage }}
|
||||
</template>
|
||||
|
||||
<!-- 2. Else, if working hours are disabled, show based on online status -->
|
||||
<template v-else-if="!workingHoursEnabled">
|
||||
{{
|
||||
isOnline
|
||||
? replyTimeMessage
|
||||
: t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE')
|
||||
}}
|
||||
</template>
|
||||
|
||||
<!-- 3. Else (not in working hours, but working hours ARE enabled) -->
|
||||
<!-- Check if all configured slots are 'closedAllDay' -->
|
||||
<template v-else-if="allDayClosed">
|
||||
{{ t('TEAM_AVAILABILITY.BACK_AS_SOON_AS_POSSIBLE') }}
|
||||
</template>
|
||||
|
||||
<!-- 4. Else (not in WH, WH enabled, not allDayClosed), calculate next slot -->
|
||||
<template v-else-if="!nextSlot">
|
||||
{{ t('REPLY_TIME.BACK_IN_SOME_TIME') }}
|
||||
</template>
|
||||
|
||||
<!-- Tomorrow -->
|
||||
<template v-else-if="nextSlot.daysUntilOpen === 1">
|
||||
{{ t('REPLY_TIME.BACK_TOMORROW') }}
|
||||
</template>
|
||||
|
||||
<!-- Multiple days away (eg: on Monday) -->
|
||||
<template v-else-if="nextSlot.daysUntilOpen > 1">
|
||||
{{
|
||||
t('REPLY_TIME.BACK_ON_DAY', {
|
||||
day: dayNames[nextSlot.config.dayOfWeek],
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
<!-- Same day - less than 1 hour (eg: in 5 minutes) -->
|
||||
<template v-else-if="nextSlot.hoursUntilOpen === 0">
|
||||
{{
|
||||
t('REPLY_TIME.BACK_IN_MINUTES', {
|
||||
time: `${roundedMinutesUntilOpen}`,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
|
||||
<!-- Same day - less than 3 hours (eg: in 2 hours) -->
|
||||
<template
|
||||
v-else-if="nextSlot.hoursUntilOpen < HOUR_THRESHOLD_FOR_EXACT_TIME"
|
||||
>
|
||||
{{ t('REPLY_TIME.BACK_IN_HOURS', adjustedHoursUntilOpen) }}
|
||||
</template>
|
||||
|
||||
<!-- Same day - 3+ hours away (eg: at 10:00 AM) -->
|
||||
<template v-else>
|
||||
{{
|
||||
t('REPLY_TIME.BACK_AT_TIME', {
|
||||
time: formattedOpeningTime,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showBannerMessage: false,
|
||||
bannerMessage: '',
|
||||
bannerType: 'error',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.SHOW_ALERT, ({ message, type = 'error' }) => {
|
||||
this.bannerMessage = message;
|
||||
this.bannerType = type;
|
||||
this.showBannerMessage = true;
|
||||
setTimeout(() => {
|
||||
this.showBannerMessage = false;
|
||||
}, 3000);
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="showBannerMessage" :class="`banner ${bannerType}`">
|
||||
<span>
|
||||
{{ bannerMessage }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.banner {
|
||||
@apply text-white text-sm font-semibold p-3 text-center;
|
||||
|
||||
&.success {
|
||||
@apply bg-n-teal-9;
|
||||
}
|
||||
|
||||
&.error {
|
||||
@apply bg-n-ruby-9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
research/chatwoot/app/javascript/widget/components/ChatAttachment.vue
Executable file
167
research/chatwoot/app/javascript/widget/components/ChatAttachment.vue
Executable file
@@ -0,0 +1,167 @@
|
||||
<script>
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import {
|
||||
checkFileSizeLimit,
|
||||
resolveMaximumFileUploadSize,
|
||||
} from 'shared/helpers/FileHelper';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
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 },
|
||||
props: {
|
||||
onAttach: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { canHandleAttachments } = useAttachments();
|
||||
return { canHandleAttachments };
|
||||
},
|
||||
data() {
|
||||
return { isUploading: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
fileUploadSizeLimit() {
|
||||
return resolveMaximumFileUploadSize(
|
||||
this.globalConfig.maximumFileUploadSize
|
||||
);
|
||||
},
|
||||
allowedFileTypes() {
|
||||
return ALLOWED_FILE_TYPES;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('paste', this.handleClipboardPaste);
|
||||
},
|
||||
unmounted() {
|
||||
document.removeEventListener('paste', this.handleClipboardPaste);
|
||||
},
|
||||
methods: {
|
||||
handleClipboardPaste(e) {
|
||||
// If file picker is not enabled, do not allow paste
|
||||
if (!this.canHandleAttachments) return;
|
||||
|
||||
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
||||
// items is a DataTransferItemList object which does not have forEach method
|
||||
const itemsArray = Array.from(items);
|
||||
itemsArray.forEach(item => {
|
||||
if (item.kind === 'file') {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
this.$refs.upload.add(file);
|
||||
}
|
||||
});
|
||||
},
|
||||
getFileType(fileType) {
|
||||
return fileType.includes('image') ? 'image' : 'file';
|
||||
},
|
||||
async onFileUpload(file) {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
await this.onDirectFileUpload(file);
|
||||
} else {
|
||||
await this.onIndirectFileUpload(file);
|
||||
}
|
||||
},
|
||||
async onDirectFileUpload(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
try {
|
||||
if (checkFileSizeLimit(file, this.fileUploadSizeLimit)) {
|
||||
const { websiteToken } = window.chatwootWebChannel;
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/widget/direct_uploads?website_token=${websiteToken}`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader('X-Auth-Token', window.authToken);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: error,
|
||||
});
|
||||
} else {
|
||||
this.onAttach({
|
||||
file: blob.signed_id,
|
||||
...this.getLocalFileAttributes(file),
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE: this.fileUploadSizeLimit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Error
|
||||
}
|
||||
this.isUploading = false;
|
||||
},
|
||||
async onIndirectFileUpload(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
this.isUploading = true;
|
||||
try {
|
||||
if (checkFileSizeLimit(file, this.fileUploadSizeLimit)) {
|
||||
await this.onAttach({
|
||||
file: file.file,
|
||||
...this.getLocalFileAttributes(file),
|
||||
});
|
||||
} else {
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE: this.fileUploadSizeLimit,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Error
|
||||
}
|
||||
this.isUploading = false;
|
||||
},
|
||||
getLocalFileAttributes(file) {
|
||||
return {
|
||||
thumbUrl: window.URL.createObjectURL(file.file),
|
||||
fileType: this.getFileType(file.type),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FileUpload
|
||||
ref="upload"
|
||||
:size="4096 * 2048"
|
||||
:accept="allowedFileTypes"
|
||||
:data="{
|
||||
direct_upload_url: '/api/v1/widget/direct_uploads',
|
||||
direct_upload: true,
|
||||
}"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<button class="min-h-8 min-w-8 flex items-center justify-center">
|
||||
<FluentIcon v-if="!isUploading.image" icon="attach" />
|
||||
<Spinner v-if="isUploading" size="small" />
|
||||
</button>
|
||||
</FileUpload>
|
||||
</template>
|
||||
152
research/chatwoot/app/javascript/widget/components/ChatFooter.vue
Executable file
152
research/chatwoot/app/javascript/widget/components/ChatFooter.vue
Executable file
@@ -0,0 +1,152 @@
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import CustomButton from 'shared/components/Button.vue';
|
||||
import FooterReplyTo from 'widget/components/FooterReplyTo.vue';
|
||||
import ChatInputWrap from 'widget/components/ChatInputWrap.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { sendEmailTranscript } from 'widget/api/conversation';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { IFrameHelper } from '../helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatInputWrap,
|
||||
CustomButton,
|
||||
FooterReplyTo,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
return { router };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
inReplyTo: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
currentUser: 'contacts/getCurrentUser',
|
||||
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
|
||||
}),
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
hideReplyBox() {
|
||||
const { allowMessagesAfterResolved } = window.chatwootWebChannel;
|
||||
const { status } = this.conversationAttributes;
|
||||
return !allowMessagesAfterResolved && status === 'resolved';
|
||||
},
|
||||
showEmailTranscriptButton() {
|
||||
return this.hasEmail;
|
||||
},
|
||||
hasEmail() {
|
||||
return this.currentUser && this.currentUser.has_email;
|
||||
},
|
||||
hasReplyTo() {
|
||||
return (
|
||||
this.inReplyTo && (this.inReplyTo.content || this.inReplyTo.attachments)
|
||||
);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.toggleReplyTo);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['sendMessage', 'sendAttachment']),
|
||||
...mapActions('conversationAttributes', ['getAttributes']),
|
||||
async handleSendMessage(content) {
|
||||
await this.sendMessage({
|
||||
content,
|
||||
replyTo: this.inReplyTo ? this.inReplyTo.id : null,
|
||||
});
|
||||
// reset replyTo message after sending
|
||||
this.inReplyTo = null;
|
||||
// Update conversation attributes on new conversation
|
||||
if (this.conversationSize === 0) {
|
||||
this.getAttributes();
|
||||
}
|
||||
},
|
||||
async handleSendAttachment(attachment) {
|
||||
await this.sendAttachment({
|
||||
attachment,
|
||||
replyTo: this.inReplyTo ? this.inReplyTo.id : null,
|
||||
});
|
||||
this.inReplyTo = null;
|
||||
},
|
||||
startNewConversation() {
|
||||
this.router.replace({ name: 'prechat-form' });
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
data: { hasConversation: true },
|
||||
});
|
||||
},
|
||||
toggleReplyTo(message) {
|
||||
this.inReplyTo = message;
|
||||
},
|
||||
async sendTranscript() {
|
||||
if (this.hasEmail) {
|
||||
try {
|
||||
await sendEmailTranscript();
|
||||
emitter.emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
emitter.$emit(BUS_EVENTS.SHOW_ALERT, {
|
||||
message: this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
v-if="!hideReplyBox"
|
||||
class="relative z-50 mb-1"
|
||||
:class="{
|
||||
'rounded-lg': !isWidgetStyleFlat,
|
||||
'pt-2.5 shadow-[0px_-20px_20px_1px_rgba(0,_0,_0,_0.05)] dark:shadow-[0px_-20px_20px_1px_rgba(0,_0,_0,_0.15)] rounded-t-none':
|
||||
hasReplyTo,
|
||||
}"
|
||||
>
|
||||
<FooterReplyTo
|
||||
v-if="hasReplyTo"
|
||||
:in-reply-to="inReplyTo"
|
||||
@dismiss="inReplyTo = null"
|
||||
/>
|
||||
<ChatInputWrap
|
||||
class="shadow-sm"
|
||||
:on-send-message="handleSendMessage"
|
||||
:on-send-attachment="handleSendAttachment"
|
||||
/>
|
||||
</footer>
|
||||
<div v-else>
|
||||
<CustomButton
|
||||
class="font-medium"
|
||||
block
|
||||
:bg-color="widgetColor"
|
||||
:text-color="textColor"
|
||||
@click="startNewConversation"
|
||||
>
|
||||
{{ $t('START_NEW_CONVERSATION') }}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
v-if="showEmailTranscriptButton"
|
||||
type="clear"
|
||||
class="font-normal"
|
||||
@click="sendTranscript"
|
||||
>
|
||||
{{ $t('EMAIL_TRANSCRIPT.BUTTON_TEXT') }}
|
||||
</CustomButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { toRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import HeaderActions from './HeaderActions.vue';
|
||||
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
|
||||
const props = defineProps({
|
||||
avatarUrl: { type: String, default: '' },
|
||||
title: { type: String, default: '' },
|
||||
showPopoutButton: { type: Boolean, default: false },
|
||||
showBackButton: { type: Boolean, default: false },
|
||||
availableAgents: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const availableAgents = toRef(props, 'availableAgents');
|
||||
|
||||
const router = useRouter();
|
||||
const { isOnline } = useAvailability(availableAgents);
|
||||
|
||||
const onBackButtonClick = () => {
|
||||
router.replace({ name: 'home' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex justify-between w-full p-5 bg-n-background gap-2">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
v-if="showBackButton"
|
||||
class="px-2 ltr:-ml-3 rtl:-mr-3"
|
||||
@click="onBackButtonClick"
|
||||
>
|
||||
<FluentIcon icon="chevron-left" size="24" class="text-n-slate-12" />
|
||||
</button>
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
class="w-8 h-8 ltr:mr-3 rtl:ml-3 rounded-full"
|
||||
:src="avatarUrl"
|
||||
alt="avatar"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="flex items-center text-base font-medium leading-4 text-n-slate-12"
|
||||
>
|
||||
<span v-dompurify-html="title" class="ltr:mr-1 rtl:ml-1" />
|
||||
<div
|
||||
:class="`h-2 w-2 rounded-full
|
||||
${isOnline ? 'bg-n-teal-10' : 'hidden'}`"
|
||||
/>
|
||||
</div>
|
||||
<AvailabilityContainer
|
||||
:agents="availableAgents"
|
||||
:show-header="false"
|
||||
:show-avatars="false"
|
||||
text-classes="text-xs leading-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HeaderActions :show-popout-button="showPopoutButton" />
|
||||
</header>
|
||||
</template>
|
||||
57
research/chatwoot/app/javascript/widget/components/ChatHeaderExpanded.vue
Executable file
57
research/chatwoot/app/javascript/widget/components/ChatHeaderExpanded.vue
Executable file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import HeaderActions from './HeaderActions.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
const props = defineProps({
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
introHeading: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
introBody: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showPopoutButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
props.avatarUrl ? 'justify-between' : 'justify-end',
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="header-expanded pt-6 pb-4 px-5 relative box-border w-full bg-transparent"
|
||||
>
|
||||
<div class="flex items-start" :class="containerClasses">
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
class="h-12 rounded-full"
|
||||
:src="avatarUrl"
|
||||
alt="Avatar"
|
||||
/>
|
||||
<HeaderActions
|
||||
:show-popout-button="showPopoutButton"
|
||||
:show-end-conversation-button="false"
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
v-dompurify-html="introHeading"
|
||||
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12 line-clamp-4"
|
||||
/>
|
||||
<p
|
||||
v-dompurify-html="formatMessage(introBody)"
|
||||
class="text-lg leading-normal text-n-slate-11 [&_a]:underline line-clamp-6"
|
||||
/>
|
||||
</header>
|
||||
</template>
|
||||
195
research/chatwoot/app/javascript/widget/components/ChatInputWrap.vue
Executable file
195
research/chatwoot/app/javascript/widget/components/ChatInputWrap.vue
Executable file
@@ -0,0 +1,195 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import ChatAttachmentButton from 'widget/components/ChatAttachment.vue';
|
||||
import ChatSendButton from 'widget/components/ChatSendButton.vue';
|
||||
import { useAttachments } from '../composables/useAttachments';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
|
||||
import EmojiInput from 'shared/components/emoji/EmojiInput.vue';
|
||||
|
||||
export default {
|
||||
name: 'ChatInputWrap',
|
||||
components: {
|
||||
ChatAttachmentButton,
|
||||
ChatSendButton,
|
||||
EmojiInput,
|
||||
FluentIcon,
|
||||
ResizableTextArea,
|
||||
},
|
||||
props: {
|
||||
onSendMessage: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onSendAttachment: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
canHandleAttachments,
|
||||
shouldShowEmojiPicker,
|
||||
hasEmojiPickerEnabled,
|
||||
} = useAttachments();
|
||||
return {
|
||||
canHandleAttachments,
|
||||
shouldShowEmojiPicker,
|
||||
hasEmojiPickerEnabled,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
userInput: '',
|
||||
showEmojiPicker: false,
|
||||
isFocused: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isWidgetOpen: 'appConfig/getIsWidgetOpen',
|
||||
shouldShowEmojiPicker: 'appConfig/getShouldShowEmojiPicker',
|
||||
}),
|
||||
showAttachment() {
|
||||
return this.canHandleAttachments && this.userInput.length === 0;
|
||||
},
|
||||
showSendButton() {
|
||||
return this.userInput.length > 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isWidgetOpen(isWidgetOpen) {
|
||||
if (isWidgetOpen) {
|
||||
this.focusInput();
|
||||
}
|
||||
},
|
||||
},
|
||||
unmounted() {
|
||||
document.removeEventListener('keypress', this.handleEnterKeyPress);
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('keypress', this.handleEnterKeyPress);
|
||||
if (this.isWidgetOpen) {
|
||||
this.focusInput();
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onBlur() {
|
||||
this.isFocused = false;
|
||||
},
|
||||
onFocus() {
|
||||
this.isFocused = true;
|
||||
},
|
||||
handleButtonClick() {
|
||||
if (this.userInput && this.userInput.trim()) {
|
||||
this.onSendMessage(this.userInput);
|
||||
}
|
||||
this.userInput = '';
|
||||
this.focusInput();
|
||||
},
|
||||
handleEnterKeyPress(e) {
|
||||
if (e.keyCode === 13 && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.handleButtonClick();
|
||||
}
|
||||
},
|
||||
toggleEmojiPicker() {
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
},
|
||||
hideEmojiPicker(e) {
|
||||
if (this.showEmojiPicker) {
|
||||
e.stopPropagation();
|
||||
this.toggleEmojiPicker();
|
||||
}
|
||||
},
|
||||
emojiOnClick(emoji) {
|
||||
this.userInput = `${this.userInput}${emoji} `;
|
||||
},
|
||||
onTypingOff() {
|
||||
this.toggleTyping('off');
|
||||
},
|
||||
onTypingOn() {
|
||||
this.toggleTyping('on');
|
||||
},
|
||||
toggleTyping(typingStatus) {
|
||||
this.$store.dispatch('conversation/toggleUserTyping', { typingStatus });
|
||||
},
|
||||
focusInput() {
|
||||
this.$refs.chatInput.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
|
||||
:class="{
|
||||
'!shadow-[var(--widget-color,#2781f6)]': isFocused,
|
||||
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
|
||||
}"
|
||||
@keydown.esc="hideEmojiPicker"
|
||||
>
|
||||
<ResizableTextArea
|
||||
id="chat-input"
|
||||
ref="chatInput"
|
||||
v-model="userInput"
|
||||
:rows="1"
|
||||
:aria-label="$t('CHAT_PLACEHOLDER')"
|
||||
:placeholder="$t('CHAT_PLACEHOLDER')"
|
||||
class="user-message-input reset-base"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
<div class="flex items-center ltr:pl-2 rtl:pr-2">
|
||||
<ChatAttachmentButton
|
||||
v-if="showAttachment"
|
||||
class="text-n-slate-12"
|
||||
:on-attach="onSendAttachment"
|
||||
/>
|
||||
<button
|
||||
v-if="shouldShowEmojiPicker && hasEmojiPickerEnabled"
|
||||
class="flex items-center justify-center min-h-8 min-w-8"
|
||||
:aria-label="$t('EMOJI.ARIA_LABEL')"
|
||||
@click="toggleEmojiPicker"
|
||||
>
|
||||
<FluentIcon
|
||||
icon="emoji"
|
||||
class="transition-all duration-150"
|
||||
:class="{
|
||||
'text-n-slate-12': !showEmojiPicker,
|
||||
'text-n-brand': showEmojiPicker,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<EmojiInput
|
||||
v-if="shouldShowEmojiPicker && showEmojiPicker"
|
||||
v-on-clickaway="hideEmojiPicker"
|
||||
:on-click="emojiOnClick"
|
||||
@keydown.esc="hideEmojiPicker"
|
||||
/>
|
||||
<ChatSendButton
|
||||
v-if="showSendButton"
|
||||
:color="widgetColor"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.emoji-dialog {
|
||||
@apply max-w-full ltr:right-5 rtl:right-[unset] rtl:left-5 -top-[302px] before:ltr:right-2.5 before:rtl:right-[unset] before:rtl:left-2.5;
|
||||
}
|
||||
|
||||
.user-message-input {
|
||||
@apply border-none outline-none w-full placeholder:text-n-slate-10 resize-none h-8 min-h-8 max-h-60 py-1 px-0 my-2 bg-n-background text-n-slate-12 transition-all duration-200;
|
||||
}
|
||||
</style>
|
||||
55
research/chatwoot/app/javascript/widget/components/ChatMessage.vue
Executable file
55
research/chatwoot/app/javascript/widget/components/ChatMessage.vue
Executable file
@@ -0,0 +1,55 @@
|
||||
<script>
|
||||
import AgentMessage from 'widget/components/AgentMessage.vue';
|
||||
import UserMessage from 'widget/components/UserMessage.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AgentMessage,
|
||||
UserMessage,
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
allMessages: 'conversation/getConversation',
|
||||
}),
|
||||
isUserMessage() {
|
||||
return this.message.message_type === MESSAGE_TYPE.INCOMING;
|
||||
},
|
||||
replyTo() {
|
||||
const replyTo = this.message?.content_attributes?.in_reply_to;
|
||||
return replyTo ? this.allMessages[replyTo] : null;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UserMessage
|
||||
v-if="isUserMessage"
|
||||
:id="`cwmsg-${message.id}`"
|
||||
:message="message"
|
||||
:reply-to="replyTo"
|
||||
/>
|
||||
<AgentMessage
|
||||
v-else
|
||||
:id="`cwmsg-${message.id}`"
|
||||
:message="message"
|
||||
:reply-to="replyTo"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
max-width: 90%;
|
||||
}
|
||||
</style>
|
||||
36
research/chatwoot/app/javascript/widget/components/ChatSendButton.vue
Executable file
36
research/chatwoot/app/javascript/widget/components/ChatSendButton.vue
Executable file
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: '#6e6f73',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="disabled"
|
||||
class="min-h-8 min-w-8 flex items-center justify-center ml-1"
|
||||
>
|
||||
<FluentIcon v-if="!loading" icon="send" :style="`color: ${color}`" />
|
||||
<Spinner v-else size="small" />
|
||||
</button>
|
||||
</template>
|
||||
149
research/chatwoot/app/javascript/widget/components/ConversationWrap.vue
Executable file
149
research/chatwoot/app/javascript/widget/components/ConversationWrap.vue
Executable file
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import ChatMessage from 'widget/components/ChatMessage.vue';
|
||||
import AgentTypingBubble from 'widget/components/AgentTypingBubble.vue';
|
||||
import DateSeparator from 'shared/components/DateSeparator.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||
import { mapActions, mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'ConversationWrap',
|
||||
components: {
|
||||
ChatMessage,
|
||||
AgentTypingBubble,
|
||||
DateSeparator,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
groupedMessages: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { darkMode } = useDarkMode();
|
||||
return { darkMode };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
previousScrollHeight: 0,
|
||||
previousConversationSize: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
earliestMessage: 'conversation/getEarliestMessage',
|
||||
lastMessage: 'conversation/getLastMessage',
|
||||
allMessagesLoaded: 'conversation/getAllMessagesLoaded',
|
||||
isFetchingList: 'conversation/getIsFetchingList',
|
||||
conversationSize: 'conversation/getConversationSize',
|
||||
isAgentTyping: 'conversation/getIsAgentTyping',
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
}),
|
||||
colorSchemeClass() {
|
||||
return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
|
||||
},
|
||||
showStatusIndicator() {
|
||||
const { status } = this.conversationAttributes;
|
||||
const isConversationInPendingStatus = status === 'pending';
|
||||
const isLastMessageIncoming =
|
||||
this.lastMessage.message_type === MESSAGE_TYPE.INCOMING;
|
||||
return (
|
||||
this.isAgentTyping ||
|
||||
(isConversationInPendingStatus && isLastMessageIncoming)
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
allMessagesLoaded() {
|
||||
this.previousScrollHeight = 0;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$el.addEventListener('scroll', this.handleScroll);
|
||||
this.scrollToBottom();
|
||||
},
|
||||
updated() {
|
||||
if (this.previousConversationSize !== this.conversationSize) {
|
||||
this.previousConversationSize = this.conversationSize;
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
unmounted() {
|
||||
this.$el.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
methods: {
|
||||
...mapActions('conversation', ['fetchOldConversations']),
|
||||
scrollToBottom() {
|
||||
const container = this.$el;
|
||||
container.scrollTop = container.scrollHeight - this.previousScrollHeight;
|
||||
this.previousScrollHeight = 0;
|
||||
},
|
||||
handleScroll() {
|
||||
if (
|
||||
this.isFetchingList ||
|
||||
this.allMessagesLoaded ||
|
||||
!this.conversationSize
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.$el.scrollTop < 100) {
|
||||
this.fetchOldConversations({ before: this.earliestMessage.id });
|
||||
this.previousScrollHeight = this.$el.scrollHeight;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversation--container" :class="colorSchemeClass">
|
||||
<div class="conversation-wrap" :class="{ 'is-typing': isAgentTyping }">
|
||||
<div v-if="isFetchingList" class="message--loader">
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-for="groupedMessage in groupedMessages"
|
||||
:key="groupedMessage.date"
|
||||
class="messages-wrap"
|
||||
>
|
||||
<DateSeparator :date="groupedMessage.date" />
|
||||
<ChatMessage
|
||||
v-for="message in groupedMessage.messages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
/>
|
||||
</div>
|
||||
<AgentTypingBubble v-if="showStatusIndicator" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversation--container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
color-scheme: light dark;
|
||||
|
||||
&.light-scheme {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
&.dark-scheme {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
.conversation-wrap {
|
||||
flex: 1;
|
||||
@apply px-2 pt-8 pb-2;
|
||||
}
|
||||
|
||||
.message--loader {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'DragWrapper',
|
||||
props: {
|
||||
direction: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['left', 'right'].includes(value),
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['dragged'],
|
||||
data() {
|
||||
return {
|
||||
startX: null,
|
||||
dragDistance: 0,
|
||||
threshold: 50, // Threshold value in pixels
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
handleTouchStart(event) {
|
||||
if (this.disabled) return;
|
||||
this.startX = event.touches[0].clientX;
|
||||
},
|
||||
handleTouchMove(event) {
|
||||
if (this.disabled) return;
|
||||
const touchX = event.touches[0].clientX;
|
||||
let deltaX = touchX - this.startX;
|
||||
|
||||
if (this.direction === 'right') {
|
||||
this.dragDistance = Math.min(this.threshold, deltaX);
|
||||
} else if (this.direction === 'left') {
|
||||
this.dragDistance = Math.max(-this.threshold, deltaX);
|
||||
}
|
||||
},
|
||||
resetPosition() {
|
||||
if (
|
||||
(this.dragDistance >= this.threshold && this.direction === 'right') ||
|
||||
(this.dragDistance <= -this.threshold && this.direction === 'left')
|
||||
) {
|
||||
this.$emit('dragged', this.direction);
|
||||
}
|
||||
this.dragDistance = 0; // Reset the position after releasing the touch
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="{ transform: `translateX(${dragDistance}px)` }"
|
||||
class="will-change-transform"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="resetPosition"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
},
|
||||
props: {
|
||||
url: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isInProgress: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
widgetColor: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isUserBubble: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.isInProgress
|
||||
? this.$t('COMPONENTS.FILE_BUBBLE.UPLOADING')
|
||||
: decodeURI(this.fileName);
|
||||
},
|
||||
fileName() {
|
||||
return this.url.substring(this.url.lastIndexOf('/') + 1);
|
||||
},
|
||||
contrastingTextColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
textColor() {
|
||||
return this.isUserBubble && this.widgetColor
|
||||
? this.contrastingTextColor
|
||||
: '';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openLink() {
|
||||
const win = window.open(this.url, '_blank');
|
||||
win.focus();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file flex flex-row items-center p-3 cursor-pointer">
|
||||
<div class="icon-wrap" :style="{ color: textColor }">
|
||||
<FluentIcon icon="document" size="28" />
|
||||
</div>
|
||||
<div class="ltr:pr-1 rtl:pl-1">
|
||||
<div
|
||||
class="m-0 font-medium text-sm"
|
||||
:class="{ 'text-n-slate-12': !isUserBubble }"
|
||||
:style="{ color: textColor }"
|
||||
>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="leading-none mb-1">
|
||||
<a
|
||||
class="download"
|
||||
rel="noreferrer noopener nofollow"
|
||||
target="_blank"
|
||||
:style="{ color: textColor }"
|
||||
:href="url"
|
||||
>
|
||||
{{ $t('COMPONENTS.FILE_BUBBLE.DOWNLOAD') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.file {
|
||||
.icon-wrap {
|
||||
@apply text-[2.5rem] text-n-brand leading-none ltr:ml-1 rtl:mr-1 ltr:mr-2 rtl:ml-2;
|
||||
}
|
||||
|
||||
.download {
|
||||
@apply text-n-brand font-medium p-0 m-0 text-xs no-underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
name: 'FooterReplyTo',
|
||||
components: { FluentIcon },
|
||||
props: {
|
||||
inReplyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['dismiss'],
|
||||
computed: {
|
||||
replyToAttachment() {
|
||||
if (!this.inReplyTo?.attachments?.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [{ file_type: fileType } = {}] = this.inReplyTo.attachments;
|
||||
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mb-2.5 rounded-[7px] bg-n-slate-3 px-2 py-1.5 text-sm text-n-slate-11 flex items-center gap-2"
|
||||
>
|
||||
<div class="items-center flex-grow truncate">
|
||||
<strong>{{ $t('FOOTER_REPLY_TO.REPLY_TO') }}</strong>
|
||||
{{ inReplyTo.content || replyToAttachment }}
|
||||
</div>
|
||||
<button
|
||||
class="items-end flex-shrink-0 p-1 rounded-md hover:bg-n-slate-5"
|
||||
@click="$emit('dismiss')"
|
||||
>
|
||||
<FluentIcon icon="dismiss" size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, useTemplateRef, nextTick, unref } from 'vue';
|
||||
import countriesList from 'shared/constants/countries.js';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import {
|
||||
getActiveCountryCode,
|
||||
getActiveDialCode,
|
||||
} from 'shared/components/PhoneInput/helper';
|
||||
|
||||
const { context } = defineProps({
|
||||
context: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const localValue = ref(context.value || '');
|
||||
|
||||
const selectedIndex = ref(-1);
|
||||
const showDropdown = ref(false);
|
||||
const searchCountry = ref('');
|
||||
const activeCountryCode = ref(getActiveCountryCode());
|
||||
const activeDialCode = ref(getActiveDialCode());
|
||||
const phoneNumber = ref('');
|
||||
|
||||
const dropdownRef = useTemplateRef('dropdownRef');
|
||||
const searchbarRef = useTemplateRef('searchbarRef');
|
||||
|
||||
const placeholder = computed(() => context?.attrs?.placeholder || '');
|
||||
const hasErrorInPhoneInput = computed(() => context?.state?.invalid);
|
||||
const dropdownFirstItemName = computed(() =>
|
||||
activeCountryCode.value ? 'Clear selection' : 'Select Country'
|
||||
);
|
||||
const countries = computed(() => [
|
||||
{
|
||||
name: dropdownFirstItemName.value,
|
||||
dial_code: '',
|
||||
emoji: '',
|
||||
id: '',
|
||||
},
|
||||
...countriesList,
|
||||
]);
|
||||
|
||||
const items = computed(() => {
|
||||
return countries.value.filter(country => {
|
||||
const { name, dial_code, id } = country;
|
||||
const search = searchCountry.value.toLowerCase();
|
||||
return (
|
||||
name.toLowerCase().includes(search) ||
|
||||
dial_code.toLowerCase().includes(search) ||
|
||||
id.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const activeCountry = computed(() => {
|
||||
return countries.value.find(
|
||||
country => country.id === activeCountryCode.value
|
||||
);
|
||||
});
|
||||
|
||||
watch(items, newItems => {
|
||||
if (newItems.length < selectedIndex.value + 1) {
|
||||
// Reset the selected index to 0 if the new items length is less than the selected index.
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
function setContextValue(code) {
|
||||
const safeCode = unref(code);
|
||||
// This function is used to set the context value.
|
||||
// The context value is used to set the value of the phone number field in the pre-chat form.
|
||||
localValue.value = `${safeCode}${phoneNumber.value}`;
|
||||
context.node.input(localValue.value);
|
||||
}
|
||||
|
||||
function onChange(e) {
|
||||
phoneNumber.value = e.target.value;
|
||||
// This function is used to set the context value when the user types in the phone number field.
|
||||
setContextValue(activeDialCode.value);
|
||||
}
|
||||
|
||||
function focusedOrActiveItem(className) {
|
||||
// This function is used to get the focused or active item in the dropdown.
|
||||
if (!showDropdown.value) return [];
|
||||
return Array.from(
|
||||
dropdownRef.value?.querySelectorAll(
|
||||
`div.country-dropdown div.country-dropdown--item.${className}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToFocusedOrActiveItem(item) {
|
||||
// This function is used to scroll the dropdown to the focused or active item.
|
||||
const focusedOrActiveItemLocal = item;
|
||||
if (focusedOrActiveItemLocal.length > 0) {
|
||||
const dropdown = dropdownRef.value;
|
||||
const dropdownHeight = dropdown.clientHeight;
|
||||
const itemTop = focusedOrActiveItemLocal[0]?.offsetTop;
|
||||
const itemHeight = focusedOrActiveItemLocal[0]?.offsetHeight;
|
||||
const scrollPosition = itemTop - dropdownHeight / 2 + itemHeight / 2;
|
||||
dropdown.scrollTo({
|
||||
top: scrollPosition,
|
||||
behavior: 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function adjustScroll() {
|
||||
nextTick(() => {
|
||||
scrollToFocusedOrActiveItem(focusedOrActiveItem('focus'));
|
||||
});
|
||||
}
|
||||
|
||||
function adjustSelection(direction) {
|
||||
if (!showDropdown.value) return;
|
||||
const maxIndex = items.value.length - 1;
|
||||
if (direction === 'up') {
|
||||
selectedIndex.value =
|
||||
selectedIndex.value <= 0 ? maxIndex : selectedIndex.value - 1;
|
||||
} else if (direction === 'down') {
|
||||
selectedIndex.value =
|
||||
selectedIndex.value >= maxIndex ? 0 : selectedIndex.value + 1;
|
||||
}
|
||||
adjustScroll();
|
||||
}
|
||||
|
||||
function moveSelectionUp() {
|
||||
adjustSelection('up');
|
||||
}
|
||||
function moveSelectionDown() {
|
||||
adjustSelection('down');
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
selectedIndex.value = -1;
|
||||
showDropdown.value = false;
|
||||
}
|
||||
|
||||
function onSelectCountry(country) {
|
||||
activeCountryCode.value = country.id;
|
||||
searchCountry.value = '';
|
||||
activeDialCode.value = country.dial_code ? country.dial_code : '';
|
||||
setContextValue(country.dial_code);
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function toggleCountryDropdown() {
|
||||
showDropdown.value = !showDropdown.value;
|
||||
selectedIndex.value = -1;
|
||||
if (showDropdown.value) {
|
||||
nextTick(() => {
|
||||
searchbarRef.value.focus();
|
||||
// This is used to scroll the dropdown to the active item.
|
||||
scrollToFocusedOrActiveItem(focusedOrActiveItem('active'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!showDropdown.value || selectedIndex.value === -1) return;
|
||||
onSelectCountry(items.value[selectedIndex.value]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative mt-2 phone-input--wrap">
|
||||
<div
|
||||
class="flex items-center justify-start outline-none phone-input rounded-lg box-border bg-n-background dark:bg-n-alpha-2 border-none outline outline-1 outline-offset-[-1px] text-sm w-full text-n-slate-12 focus-within:outline-n-brand focus-within:ring-1 focus-within:ring-n-brand"
|
||||
:class="{
|
||||
'outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9':
|
||||
hasErrorInPhoneInput,
|
||||
'outline-n-weak': !hasErrorInPhoneInput,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between h-[2.625rem] px-2 py-2 cursor-pointer bg-n-alpha-1 dark:bg-n-solid-1 ltr:rounded-bl-lg rtl:rounded-br-lg ltr:rounded-tl-lg rtl:rounded-tr-lg min-w-[3.6rem] w-[3.6rem]"
|
||||
@click="toggleCountryDropdown"
|
||||
>
|
||||
<h5 v-if="activeCountry.emoji" class="mb-0 text-xl">
|
||||
{{ activeCountry.emoji }}
|
||||
</h5>
|
||||
<FluentIcon v-else icon="globe" class="fluent-icon" size="20" />
|
||||
<FluentIcon icon="chevron-down" class="fluent-icon" size="12" />
|
||||
</div>
|
||||
<span
|
||||
v-if="activeDialCode"
|
||||
class="py-2 ltr:pl-2 rtl:pr-2 text-base text-n-slate-11"
|
||||
>
|
||||
{{ activeDialCode }}
|
||||
</span>
|
||||
<input
|
||||
:value="phoneNumber"
|
||||
type="phoneInput"
|
||||
class="w-full h-full !py-3 pl-2 pr-3 leading-tight rounded-r !outline-none focus:!ring-0 !bg-transparent dark:!bg-transparent"
|
||||
name="phoneNumber"
|
||||
:placeholder="placeholder"
|
||||
@input="onChange"
|
||||
@blur="context.blurHandler"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
ref="dropdownRef"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="country-dropdown absolute bg-n-background text-n-slate-12 dark:bg-n-solid-3 z-10 h-48 px-0 pt-0 pb-1 pl-1 pr-1 overflow-y-auto rounded-lg shadow-lg top-12 w-full min-w-24 max-w-[14.8rem]"
|
||||
@keydown.up="moveSelectionUp"
|
||||
@keydown.down="moveSelectionDown"
|
||||
@keydown.enter="onSelect"
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 bg-n-background text-n-slate-12 dark:bg-n-solid-3"
|
||||
>
|
||||
<input
|
||||
ref="searchbarRef"
|
||||
v-model="searchCountry"
|
||||
type="text"
|
||||
:placeholder="$t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_SEARCH')"
|
||||
class="w-full h-8 !ring-0 px-3 py-2 mt-1 mb-1 text-sm rounded bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(country, index) in items"
|
||||
:key="index"
|
||||
class="flex items-center h-8 px-2 py-2 rounded cursor-pointer country-dropdown--item text-n-slate-12 dark:hover:bg-n-solid-2 hover:bg-n-alpha-2"
|
||||
:class="[
|
||||
country.id === activeCountryCode &&
|
||||
'active bg-n-alpha-1 dark:bg-n-solid-1',
|
||||
index === selectedIndex && 'focus dark:bg-n-solid-2 bg-n-alpha-2',
|
||||
]"
|
||||
@click="onSelectCountry(country)"
|
||||
>
|
||||
<span v-if="country.emoji" class="mr-2 text-xl">{{
|
||||
country.emoji
|
||||
}}</span>
|
||||
<span class="text-sm leading-5 truncate">
|
||||
{{ country.name }}
|
||||
</span>
|
||||
<span class="ml-2 text-xs">{{ country.dial_code }}</span>
|
||||
</div>
|
||||
<div v-if="items.length === 0">
|
||||
<span
|
||||
class="flex justify-center mt-4 text-sm text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_EMPTY') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import { defineProps, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 4,
|
||||
},
|
||||
});
|
||||
|
||||
const usersToDisplay = computed(() => props.users.slice(0, props.limit));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-for="(user, index) in usersToDisplay"
|
||||
:key="user.id"
|
||||
:class="index ? 'ltr:-ml-4 rtl:-mr-4' : ''"
|
||||
class="inline-block rounded-full text-white shadow-solid"
|
||||
>
|
||||
<Avatar
|
||||
:name="user.name"
|
||||
:src="user.avatar_url"
|
||||
:size="36"
|
||||
class="[&>span]:outline [&>span]:outline-1 [&>span]:outline-n-background"
|
||||
rounded-full
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
|
||||
import { popoutChatWindow } from '../helpers/popoutHelper';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'HeaderActions',
|
||||
components: { FluentIcon },
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
showPopoutButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showEndConversationButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
conversationAttributes: 'conversationAttributes/getConversationParams',
|
||||
canUserEndConversation: 'appConfig/getCanUserEndConversation',
|
||||
}),
|
||||
canLeaveConversation() {
|
||||
return [
|
||||
CONVERSATION_STATUS.OPEN,
|
||||
CONVERSATION_STATUS.SNOOZED,
|
||||
CONVERSATION_STATUS.PENDING,
|
||||
].includes(this.conversationStatus);
|
||||
},
|
||||
isIframe() {
|
||||
return IFrameHelper.isIFrame();
|
||||
},
|
||||
isRNWebView() {
|
||||
return RNHelper.isRNWebView();
|
||||
},
|
||||
showHeaderActions() {
|
||||
return this.isIframe || this.isRNWebView || this.hasWidgetOptions;
|
||||
},
|
||||
conversationStatus() {
|
||||
return this.conversationAttributes.status;
|
||||
},
|
||||
hasWidgetOptions() {
|
||||
return this.showPopoutButton || this.conversationStatus === 'open';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
popoutWindow() {
|
||||
this.closeWindow();
|
||||
const {
|
||||
location: { origin },
|
||||
chatwootWebChannel: { websiteToken },
|
||||
authToken,
|
||||
} = window;
|
||||
popoutChatWindow(
|
||||
origin,
|
||||
websiteToken,
|
||||
this.$root.$i18n.locale,
|
||||
authToken
|
||||
);
|
||||
},
|
||||
closeWindow() {
|
||||
if (IFrameHelper.isIFrame()) {
|
||||
IFrameHelper.sendMessage({ event: 'closeWindow' });
|
||||
} else if (RNHelper.isRNWebView) {
|
||||
RNHelper.sendMessage({ type: 'close-widget' });
|
||||
}
|
||||
},
|
||||
resolveConversation() {
|
||||
this.$store.dispatch('conversation/resolveConversation');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="showHeaderActions" class="actions flex items-center gap-3">
|
||||
<button
|
||||
v-if="
|
||||
canLeaveConversation &&
|
||||
canUserEndConversation &&
|
||||
hasEndConversationEnabled &&
|
||||
showEndConversationButton
|
||||
"
|
||||
class="button transparent compact"
|
||||
:title="$t('END_CONVERSATION')"
|
||||
@click="resolveConversation"
|
||||
>
|
||||
<FluentIcon icon="sign-out" size="22" class="text-n-slate-12" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showPopoutButton"
|
||||
class="button transparent compact new-window--button"
|
||||
@click="popoutWindow"
|
||||
>
|
||||
<FluentIcon icon="open" size="22" class="text-n-slate-12" />
|
||||
</button>
|
||||
<button
|
||||
class="button transparent compact close-button"
|
||||
:class="{
|
||||
'rn-close-button': isRNWebView,
|
||||
}"
|
||||
@click="closeWindow"
|
||||
>
|
||||
<FluentIcon icon="dismiss" size="24" class="text-n-slate-12" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.actions {
|
||||
.close-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rn-close-button {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
url: { type: String, default: '' },
|
||||
thumb: { type: String, default: '' },
|
||||
readableTime: { type: String, default: '' },
|
||||
},
|
||||
emits: ['error'],
|
||||
methods: {
|
||||
onImgError() {
|
||||
this.$emit('error');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener nofollow"
|
||||
class="image"
|
||||
>
|
||||
<div class="wrap">
|
||||
<img :src="thumb" alt="Picture message" @error="onImgError" />
|
||||
<span class="time">{{ readableTime }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.image {
|
||||
display: block;
|
||||
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
|
||||
&::before {
|
||||
background-image: linear-gradient(-180deg, transparent 3%, #1f2d3d 130%);
|
||||
bottom: 0;
|
||||
content: '';
|
||||
height: 20%;
|
||||
left: 0;
|
||||
opacity: 0.8;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.time {
|
||||
@apply text-xs bottom-1 text-white ltr:right-3 rtl:left-3 whitespace-nowrap absolute;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
name: 'MessageReplyButton',
|
||||
components: { FluentIcon },
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="p-1 mb-1 rounded-full text-n-slate-11 bg-n-slate-3 hover:text-n-slate-12"
|
||||
>
|
||||
<FluentIcon icon="arrow-reply" size="11" class="flex-shrink-0" />
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,356 @@
|
||||
<script>
|
||||
import CustomButton from 'shared/components/Button.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import { FormKit, createInput } from '@formkit/vue';
|
||||
import PhoneInput from 'widget/components/Form/PhoneInput.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CustomButton,
|
||||
Spinner,
|
||||
FormKit,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
emits: ['submitPreChat'],
|
||||
setup() {
|
||||
const phoneInput = createInput(PhoneInput, {
|
||||
props: ['hasErrorInPhoneInput'],
|
||||
});
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
return { formatMessage, phoneInput };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
locale: this.$root.$i18n.locale,
|
||||
hasErrorInPhoneInput: false,
|
||||
message: '',
|
||||
formValues: {},
|
||||
labels: {
|
||||
emailAddress: 'EMAIL_ADDRESS',
|
||||
fullName: 'FULL_NAME',
|
||||
phoneNumber: 'PHONE_NUMBER',
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
isCreating: 'conversation/getIsCreating',
|
||||
isConversationRouting: 'appConfig/getIsUpdatingRoute',
|
||||
activeCampaign: 'campaign/getActiveCampaign',
|
||||
currentUser: 'contacts/getCurrentUser',
|
||||
}),
|
||||
isCreatingConversation() {
|
||||
return this.isCreating || this.isConversationRouting;
|
||||
},
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
hasActiveCampaign() {
|
||||
return !isEmptyObject(this.activeCampaign);
|
||||
},
|
||||
shouldShowHeaderMessage() {
|
||||
return (
|
||||
this.hasActiveCampaign ||
|
||||
(this.preChatFormEnabled && !!this.headerMessage)
|
||||
);
|
||||
},
|
||||
headerMessage() {
|
||||
if (this.preChatFormEnabled) {
|
||||
return this.options.preChatMessage;
|
||||
}
|
||||
if (this.hasActiveCampaign) {
|
||||
return this.$t('PRE_CHAT_FORM.CAMPAIGN_HEADER');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
preChatFields() {
|
||||
return this.preChatFormEnabled ? this.options.preChatFields : [];
|
||||
},
|
||||
filteredPreChatFields() {
|
||||
const isUserEmailAvailable = this.currentUser.has_email;
|
||||
const isUserPhoneNumberAvailable = this.currentUser.has_phone_number;
|
||||
const isUserIdentifierAvailable = !!this.currentUser.identifier;
|
||||
|
||||
const isUserNameAvailable = !!(
|
||||
isUserIdentifierAvailable ||
|
||||
isUserEmailAvailable ||
|
||||
isUserPhoneNumberAvailable
|
||||
);
|
||||
return this.preChatFields.filter(field => {
|
||||
if (isUserEmailAvailable && field.name === 'emailAddress') {
|
||||
return false;
|
||||
}
|
||||
if (isUserPhoneNumberAvailable && field.name === 'phoneNumber') {
|
||||
return false;
|
||||
}
|
||||
if (isUserNameAvailable && field.name === 'fullName') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
enabledPreChatFields() {
|
||||
return this.filteredPreChatFields
|
||||
.filter(field => field.enabled)
|
||||
.map(field => ({
|
||||
...field,
|
||||
type:
|
||||
field.name === 'phoneNumber'
|
||||
? this.phoneInput
|
||||
: this.findFieldType(field.type),
|
||||
}));
|
||||
},
|
||||
conversationCustomAttributes() {
|
||||
let conversationAttributes = {};
|
||||
this.enabledPreChatFields.forEach(field => {
|
||||
if (field.field_type === 'conversation_attribute') {
|
||||
conversationAttributes = {
|
||||
...conversationAttributes,
|
||||
[field.name]: this.getValue(field),
|
||||
};
|
||||
}
|
||||
});
|
||||
return conversationAttributes;
|
||||
},
|
||||
contactCustomAttributes() {
|
||||
let contactAttributes = {};
|
||||
this.enabledPreChatFields.forEach(field => {
|
||||
if (field.field_type === 'contact_attribute') {
|
||||
contactAttributes = {
|
||||
...contactAttributes,
|
||||
[field.name]: this.getValue(field),
|
||||
};
|
||||
}
|
||||
});
|
||||
return contactAttributes;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
labelClass(input) {
|
||||
const { state } = input.context;
|
||||
const hasErrors = state.invalid;
|
||||
return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10';
|
||||
},
|
||||
inputClass(input) {
|
||||
const { state, family: classification, type } = input.context;
|
||||
const hasErrors = state.invalid;
|
||||
if (classification === 'box' && type === 'checkbox') {
|
||||
return '';
|
||||
}
|
||||
if (type === 'phoneInput') {
|
||||
this.hasErrorInPhoneInput = hasErrors;
|
||||
}
|
||||
if (!hasErrors) {
|
||||
return `mt-1 rounded w-full py-2 px-3`;
|
||||
}
|
||||
return `mt-1 rounded w-full py-2 px-3 error`;
|
||||
},
|
||||
isContactFieldRequired(field) {
|
||||
return this.preChatFields.find(option => option.name === field).required;
|
||||
},
|
||||
getLabel({ label }) {
|
||||
return label;
|
||||
},
|
||||
getPlaceHolder({ placeholder }) {
|
||||
return placeholder;
|
||||
},
|
||||
getValue({ name, type }) {
|
||||
if (type === 'select') {
|
||||
return this.enabledPreChatFields.find(option => option.name === name)
|
||||
.values[this.formValues[name]];
|
||||
}
|
||||
return this.formValues[name] || null;
|
||||
},
|
||||
getValidation({ type, name, field_type, regex_pattern }) {
|
||||
let regex = regex_pattern ? getRegexp(regex_pattern) : null;
|
||||
const validations = {
|
||||
emailAddress: 'email',
|
||||
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
|
||||
url: 'url',
|
||||
date: 'date',
|
||||
text: null,
|
||||
select: null,
|
||||
number: null,
|
||||
checkbox: false,
|
||||
contact_attribute: regex ? [['matches', regex]] : null,
|
||||
conversation_attribute: regex ? [['matches', regex]] : null,
|
||||
};
|
||||
const validationKeys = Object.keys(validations);
|
||||
const isRequired = this.isContactFieldRequired(name);
|
||||
const validation = isRequired ? ['required'] : ['optional'];
|
||||
|
||||
if (
|
||||
validationKeys.includes(name) ||
|
||||
validationKeys.includes(type) ||
|
||||
validationKeys.includes(field_type)
|
||||
) {
|
||||
const validationType =
|
||||
validations[type] || validations[name] || validations[field_type];
|
||||
const allValidations = validationType
|
||||
? validation.concat(validationType)
|
||||
: validation;
|
||||
return allValidations.join('|');
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
findFieldType(type) {
|
||||
if (type === 'link') {
|
||||
return 'url';
|
||||
}
|
||||
if (type === 'list') {
|
||||
return 'select';
|
||||
}
|
||||
|
||||
return type;
|
||||
},
|
||||
getOptions(item) {
|
||||
if (item.type === 'select') {
|
||||
let values = {};
|
||||
item.values.forEach((value, index) => {
|
||||
values = {
|
||||
...values,
|
||||
[index]: value,
|
||||
};
|
||||
});
|
||||
return values;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
onSubmit() {
|
||||
const { emailAddress, fullName, phoneNumber, message } = this.formValues;
|
||||
this.$emit('submitPreChat', {
|
||||
fullName,
|
||||
phoneNumber,
|
||||
emailAddress,
|
||||
message,
|
||||
activeCampaignId: this.activeCampaign.id,
|
||||
conversationCustomAttributes: this.conversationCustomAttributes,
|
||||
contactCustomAttributes: this.contactCustomAttributes,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- hide the default submit button for now -->
|
||||
<FormKit
|
||||
v-model="formValues"
|
||||
type="form"
|
||||
form-class="flex flex-col flex-1 w-full p-6 overflow-y-auto"
|
||||
:incomplete-message="false"
|
||||
:submit-attrs="{
|
||||
inputClass: 'hidden',
|
||||
wrapperClass: 'hidden',
|
||||
}"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<div
|
||||
v-if="shouldShowHeaderMessage"
|
||||
v-dompurify-html="formatMessage(headerMessage, false)"
|
||||
class="mb-4 text-base leading-5 text-n-slate-12 [&>p>.link]:text-n-blue-11 [&>p>.link]:hover:underline"
|
||||
/>
|
||||
<!-- Why do the v-bind shenanigan? Because Formkit API is really bad.
|
||||
If we just pass the options as is even with null or undefined or false,
|
||||
it assumes we are trying to make a multicheckbox. This is the best we have for now -->
|
||||
<FormKit
|
||||
v-for="item in enabledPreChatFields"
|
||||
:key="item.name"
|
||||
:name="item.name"
|
||||
:type="item.type"
|
||||
:label="getLabel(item)"
|
||||
:placeholder="getPlaceHolder(item)"
|
||||
:validation="getValidation(item)"
|
||||
v-bind="
|
||||
item.type === 'select'
|
||||
? {
|
||||
options: getOptions(item),
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:label-class="context => `text-sm font-medium ${labelClass(context)}`"
|
||||
:input-class="context => inputClass(context)"
|
||||
:validation-messages="{
|
||||
startsWithPlus: $t(
|
||||
'PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DIAL_CODE_VALID_ERROR'
|
||||
),
|
||||
isValidPhoneNumber: $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR'),
|
||||
email: $t('PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'),
|
||||
required: $t('PRE_CHAT_FORM.REQUIRED'),
|
||||
matches: item.regex_cue
|
||||
? item.regex_cue
|
||||
: $t('PRE_CHAT_FORM.REGEX_ERROR'),
|
||||
}"
|
||||
:has-error-in-phone-input="hasErrorInPhoneInput"
|
||||
/>
|
||||
<FormKit
|
||||
v-if="!hasActiveCampaign"
|
||||
name="message"
|
||||
type="textarea"
|
||||
:label-class="context => `text-sm font-medium ${labelClass(context)}`"
|
||||
:input-class="context => inputClass(context)"
|
||||
:label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')"
|
||||
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
|
||||
validation="required"
|
||||
:validation-messages="{
|
||||
required: $t('PRE_CHAT_FORM.FIELDS.MESSAGE.ERROR'),
|
||||
}"
|
||||
/>
|
||||
|
||||
<CustomButton
|
||||
class="mt-3 mb-5 font-medium flex items-center justify-center gap-2"
|
||||
block
|
||||
:bg-color="widgetColor"
|
||||
:text-color="textColor"
|
||||
:disabled="isCreatingConversation"
|
||||
>
|
||||
<Spinner v-if="isCreatingConversation" class="p-0" />
|
||||
{{ $t('START_CONVERSATION') }}
|
||||
</CustomButton>
|
||||
</FormKit>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.formkit-outer {
|
||||
@apply mt-2;
|
||||
|
||||
.formkit-inner {
|
||||
input.error,
|
||||
textarea.error,
|
||||
select.error {
|
||||
@apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
@apply size-4 outline-none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-invalid] .formkit-message {
|
||||
@apply text-n-ruby-10 block text-xs font-normal my-0.5 w-full;
|
||||
}
|
||||
|
||||
.formkit-outer[data-type='checkbox'] .formkit-wrapper {
|
||||
@apply flex items-center gap-2 px-0.5;
|
||||
}
|
||||
|
||||
.formkit-messages {
|
||||
@apply list-none m-0 p-0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script>
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
name: 'AgentMessage',
|
||||
components: {
|
||||
FluentIcon,
|
||||
},
|
||||
props: {
|
||||
replyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timeOutID: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
replyToAttachment() {
|
||||
if (!this.replyTo?.attachments?.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [{ file_type: fileType } = {}] = this.replyTo.attachments;
|
||||
return this.$t(`ATTACHMENTS.${fileType}.CONTENT`);
|
||||
},
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
clearTimeout(this.timeOutID);
|
||||
},
|
||||
methods: {
|
||||
navigateTo(id) {
|
||||
const elementId = `cwmsg-${id}`;
|
||||
this.$nextTick(() => {
|
||||
const el = document.getElementById(elementId);
|
||||
el.scrollIntoView();
|
||||
el.classList.add('bg-n-slate-3', 'dark:bg-n-solid-3');
|
||||
this.timeOutID = setTimeout(() => {
|
||||
el.classList.remove('bg-n-slate-3', 'dark:bg-n-solid-3');
|
||||
}, 500);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="px-1.5 py-0.5 rounded-md text-n-slate-11 bg-n-slate-4 opacity-60 hover:opacity-100 cursor-pointer flex items-center gap-1.5"
|
||||
@click="navigateTo(replyTo.id)"
|
||||
>
|
||||
<FluentIcon icon="arrow-reply" size="12" class="flex-shrink-0" />
|
||||
<div class="truncate max-w-[8rem]">
|
||||
{{ replyTo.content || replyToAttachment }}
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
import AvailabilityContainer from 'widget/components/Availability/AvailabilityContainer.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
const props = defineProps({
|
||||
availableAgents: { type: Array, default: () => [] },
|
||||
hasConversation: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['startConversation']);
|
||||
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
|
||||
const startConversation = () => {
|
||||
emit('startConversation');
|
||||
if (!props.hasConversation) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_START_CONVERSATION,
|
||||
data: { hasConversation: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-3 w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
|
||||
>
|
||||
<AvailabilityContainer :agents="availableAgents" show-header show-avatars />
|
||||
|
||||
<button
|
||||
class="inline-flex items-center gap-1 font-medium text-n-slate-12"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="startConversation"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
hasConversation
|
||||
? $t('CONTINUE_CONVERSATION')
|
||||
: $t('START_CONVERSATION')
|
||||
}}
|
||||
</span>
|
||||
<i class="i-lucide-chevron-right size-5 mt-px" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import {
|
||||
ON_CAMPAIGN_MESSAGE_CLICK,
|
||||
ON_UNREAD_MESSAGE_CLICK,
|
||||
} from '../constants/widgetBusEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
name: 'UnreadMessage',
|
||||
components: { Avatar },
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showSender: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sender: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
campaignId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
|
||||
useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
getPlainText,
|
||||
truncateMessage,
|
||||
highlightContent,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
companyName() {
|
||||
return `${this.$t('UNREAD_VIEW.COMPANY_FROM')} ${
|
||||
this.channelConfig.websiteName
|
||||
}`;
|
||||
},
|
||||
avatarUrl() {
|
||||
// eslint-disable-next-line
|
||||
const displayImage = this.useInboxAvatarForBot
|
||||
? this.inboxAvatarUrl
|
||||
: '/assets/images/chatwoot_bot.png';
|
||||
if (this.isSenderExist(this.sender)) {
|
||||
const { avatar_url: avatarUrl } = this.sender;
|
||||
return avatarUrl;
|
||||
}
|
||||
return displayImage;
|
||||
},
|
||||
agentName() {
|
||||
if (this.isSenderExist(this.sender)) {
|
||||
const { available_name: availableName } = this.sender;
|
||||
return availableName;
|
||||
}
|
||||
if (this.useInboxAvatarForBot) {
|
||||
return this.channelConfig.websiteName;
|
||||
}
|
||||
return this.$t('UNREAD_VIEW.BOT');
|
||||
},
|
||||
availabilityStatus() {
|
||||
if (this.isSenderExist(this.sender)) {
|
||||
const { availability_status: availabilityStatus } = this.sender;
|
||||
return availabilityStatus;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isSenderExist(sender) {
|
||||
return sender && !isEmptyObject(sender);
|
||||
},
|
||||
onClickMessage() {
|
||||
if (this.campaignId) {
|
||||
emitter.emit(ON_CAMPAIGN_MESSAGE_CLICK, this.campaignId);
|
||||
} else {
|
||||
emitter.emit(ON_UNREAD_MESSAGE_CLICK);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-bubble-wrap">
|
||||
<button class="chat-bubble agent bg-white" @click="onClickMessage">
|
||||
<div v-if="showSender" class="row--agent-block">
|
||||
<Avatar
|
||||
:src="avatarUrl"
|
||||
:size="20"
|
||||
:name="agentName"
|
||||
:status="availabilityStatus"
|
||||
rounded-full
|
||||
/>
|
||||
<span v-dompurify-html="agentName" class="agent--name" />
|
||||
<span v-dompurify-html="companyName" class="company--name" />
|
||||
</div>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message, false)"
|
||||
class="message-content"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-bubble {
|
||||
@apply max-w-[85%] cursor-pointer p-4;
|
||||
}
|
||||
|
||||
.row--agent-block {
|
||||
@apply items-center flex text-left pb-2 text-xs;
|
||||
|
||||
.agent--name {
|
||||
@apply font-medium ml-1;
|
||||
}
|
||||
|
||||
.company--name {
|
||||
@apply text-n-slate-11 dark:text-n-slate-10 ml-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import configMixin from '../mixins/configMixin';
|
||||
import { ON_UNREAD_MESSAGE_CLICK } from '../constants/widgetBusEvents';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import UnreadMessage from 'widget/components/UnreadMessage.vue';
|
||||
import { isWidgetColorLighter } from 'shared/helpers/colorHelper';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
name: 'Unread',
|
||||
components: {
|
||||
FluentIcon,
|
||||
UnreadMessage,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
messages: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ['close'],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
unreadMessageCount: 'conversation/getUnreadMessageCount',
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
}),
|
||||
sender() {
|
||||
const [firstMessage] = this.messages;
|
||||
return firstMessage.sender || {};
|
||||
},
|
||||
isBackgroundLighter() {
|
||||
return isWidgetColorLighter(this.widgetColor);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openConversationView() {
|
||||
emitter.emit(ON_UNREAD_MESSAGE_CLICK);
|
||||
},
|
||||
closeFullView() {
|
||||
this.$emit('close');
|
||||
},
|
||||
getMessageContent(message) {
|
||||
const { attachments, content } = message;
|
||||
const hasAttachments = attachments && attachments.length;
|
||||
|
||||
if (content) return content;
|
||||
|
||||
if (hasAttachments) return `📑`;
|
||||
|
||||
return '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="unread-wrap" dir="ltr">
|
||||
<div class="close-unread-wrap">
|
||||
<button class="button small close-unread-button" @click="closeFullView">
|
||||
<span class="flex items-center">
|
||||
<FluentIcon class="mr-1" icon="dismiss" size="12" />
|
||||
{{ $t('UNREAD_VIEW.CLOSE_MESSAGES_BUTTON') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="unread-messages">
|
||||
<UnreadMessage
|
||||
v-for="(message, index) in messages"
|
||||
:key="message.id"
|
||||
:message-type="message.messageType"
|
||||
:message-id="message.id"
|
||||
:show-sender="!index"
|
||||
:sender="message.sender"
|
||||
:message="getMessageContent(message)"
|
||||
:campaign-id="message.campaignId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="open-read-view-wrap">
|
||||
<button
|
||||
v-if="unreadMessageCount"
|
||||
class="button clear-button"
|
||||
@click="openConversationView"
|
||||
>
|
||||
<span
|
||||
class="flex items-center"
|
||||
:class="{
|
||||
'!text-n-slate-12': isBackgroundLighter,
|
||||
}"
|
||||
:style="{
|
||||
color: widgetColor,
|
||||
}"
|
||||
>
|
||||
<FluentIcon class="mr-2" size="16" icon="arrow-right" />
|
||||
{{ $t('UNREAD_VIEW.VIEW_MESSAGES_BUTTON') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unread-wrap {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 100vh;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
|
||||
.unread-messages {
|
||||
@apply pb-2;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
transition: all 0.3s cubic-bezier(0.17, 0.67, 0.83, 0.67);
|
||||
@apply bg-transparent text-n-brand border-none border-0 font-semibold text-base ml-1 py-0 pl-0 pr-2.5 hover:brightness-75 hover:translate-x-1;
|
||||
}
|
||||
|
||||
.close-unread-button {
|
||||
transition: all 0.3s cubic-bezier(0.17, 0.67, 0.83, 0.67);
|
||||
@apply bg-n-slate-3 dark:bg-n-slate-12 text-n-slate-12 dark:text-n-slate-1 hover:brightness-95 border-none border-0 font-medium text-xxs rounded-2xl mb-3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
182
research/chatwoot/app/javascript/widget/components/UserMessage.vue
Executable file
182
research/chatwoot/app/javascript/widget/components/UserMessage.vue
Executable file
@@ -0,0 +1,182 @@
|
||||
<script>
|
||||
import UserMessageBubble from 'widget/components/UserMessageBubble.vue';
|
||||
import MessageReplyButton from 'widget/components/MessageReplyButton.vue';
|
||||
import ImageBubble from 'widget/components/ImageBubble.vue';
|
||||
import VideoBubble from 'widget/components/VideoBubble.vue';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import FileBubble from 'widget/components/FileBubble.vue';
|
||||
import { messageStamp } from 'shared/helpers/timeHelper';
|
||||
import messageMixin from '../mixins/messageMixin';
|
||||
import ReplyToChip from 'widget/components/ReplyToChip.vue';
|
||||
import DragWrapper from 'widget/components/DragWrapper.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
name: 'UserMessage',
|
||||
components: {
|
||||
UserMessageBubble,
|
||||
MessageReplyButton,
|
||||
ImageBubble,
|
||||
VideoBubble,
|
||||
FileBubble,
|
||||
FluentIcon,
|
||||
ReplyToChip,
|
||||
DragWrapper,
|
||||
},
|
||||
mixins: [messageMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
replyTo: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hasImageError: false,
|
||||
hasVideoError: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
}),
|
||||
|
||||
isInProgress() {
|
||||
const { status = '' } = this.message;
|
||||
return status === 'in_progress';
|
||||
},
|
||||
showTextBubble() {
|
||||
const { message } = this;
|
||||
return !!message.content;
|
||||
},
|
||||
readableTime() {
|
||||
const { created_at: createdAt = '' } = this.message;
|
||||
return messageStamp(createdAt);
|
||||
},
|
||||
isFailed() {
|
||||
const { status = '' } = this.message;
|
||||
return status === 'failed';
|
||||
},
|
||||
errorMessage() {
|
||||
const { meta } = this.message;
|
||||
return meta
|
||||
? meta.error
|
||||
: this.$t('COMPONENTS.MESSAGE_BUBBLE.ERROR_MESSAGE');
|
||||
},
|
||||
hasReplyTo() {
|
||||
return this.replyTo && (this.replyTo.content || this.replyTo.attachments);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
message() {
|
||||
this.hasImageError = false;
|
||||
this.hasVideoError = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.hasImageError = false;
|
||||
this.hasVideoError = false;
|
||||
},
|
||||
methods: {
|
||||
async retrySendMessage() {
|
||||
await this.$store.dispatch(
|
||||
'conversation/sendMessageWithData',
|
||||
this.message
|
||||
);
|
||||
},
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
},
|
||||
onVideoLoadError() {
|
||||
this.hasVideoError = true;
|
||||
},
|
||||
toggleReply() {
|
||||
emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.message);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-message-wrap group">
|
||||
<div class="flex gap-1 user-message">
|
||||
<div
|
||||
class="message-wrap"
|
||||
:class="{ 'in-progress': isInProgress, 'is-failed': isFailed }"
|
||||
>
|
||||
<div v-if="hasReplyTo" class="flex justify-end mt-2 mb-1 text-xs">
|
||||
<ReplyToChip :reply-to="replyTo" />
|
||||
</div>
|
||||
<div class="flex justify-end gap-1">
|
||||
<div class="flex flex-col justify-end">
|
||||
<MessageReplyButton
|
||||
v-if="!isInProgress && !isFailed"
|
||||
class="transition-opacity delay-75 opacity-0 group-hover:opacity-100 sm:opacity-0"
|
||||
@click="toggleReply"
|
||||
/>
|
||||
</div>
|
||||
<DragWrapper direction="left" @dragged="toggleReply">
|
||||
<UserMessageBubble
|
||||
v-if="showTextBubble"
|
||||
:message="message.content"
|
||||
:status="message.status"
|
||||
:widget-color="widgetColor"
|
||||
/>
|
||||
<div
|
||||
v-if="hasAttachments"
|
||||
class="chat-bubble has-attachment user"
|
||||
:style="{ backgroundColor: widgetColor }"
|
||||
>
|
||||
<div
|
||||
v-for="attachment in message.attachments"
|
||||
:key="attachment.id"
|
||||
>
|
||||
<ImageBubble
|
||||
v-if="attachment.file_type === 'image' && !hasImageError"
|
||||
:url="attachment.data_url"
|
||||
:thumb="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onImageLoadError"
|
||||
/>
|
||||
|
||||
<VideoBubble
|
||||
v-if="attachment.file_type === 'video' && !hasVideoError"
|
||||
:url="attachment.data_url"
|
||||
:readable-time="readableTime"
|
||||
@error="onVideoLoadError"
|
||||
/>
|
||||
|
||||
<FileBubble
|
||||
v-else
|
||||
:url="attachment.data_url"
|
||||
:is-in-progress="isInProgress"
|
||||
:widget-color="widgetColor"
|
||||
is-user-bubble
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DragWrapper>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFailed"
|
||||
class="flex justify-end px-4 py-2 text-n-ruby-9 align-middle"
|
||||
>
|
||||
<button
|
||||
v-if="!hasAttachments"
|
||||
:title="$t('COMPONENTS.MESSAGE_BUBBLE.RETRY')"
|
||||
class="inline-flex items-center justify-center ltr:ml-2 rtl:mr-2"
|
||||
@click="retrySendMessage"
|
||||
>
|
||||
<FluentIcon icon="arrow-clockwise" size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
61
research/chatwoot/app/javascript/widget/components/UserMessageBubble.vue
Executable file
61
research/chatwoot/app/javascript/widget/components/UserMessageBubble.vue
Executable file
@@ -0,0 +1,61 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
|
||||
export default {
|
||||
name: 'UserMessageBubble',
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
widgetColor: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-dompurify-html="formatMessage(message, false)"
|
||||
class="chat-bubble user"
|
||||
:style="{ background: widgetColor, color: textColor }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.chat-bubble.user::v-deep {
|
||||
p code {
|
||||
@apply bg-n-alpha-2 dark:bg-n-alpha-1 text-white;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply text-white bg-n-alpha-2 dark:bg-n-alpha-1;
|
||||
|
||||
code {
|
||||
@apply bg-transparent text-white;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply bg-transparent border-n-slate-7 ltr:border-l-2 rtl:border-r-2 border-solid;
|
||||
|
||||
p {
|
||||
@apply text-n-slate-5 dark:text-n-slate-12/90;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
url: { type: String, default: '' },
|
||||
readableTime: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
|
||||
const onVideoError = () => {
|
||||
emit('error');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative block max-w-full">
|
||||
<video
|
||||
class="w-full max-w-[250px] h-auto"
|
||||
:src="url"
|
||||
controls
|
||||
@error="onVideoError"
|
||||
/>
|
||||
<span
|
||||
class="absolute text-xs text-white dark:text-white right-3 bottom-1 whitespace-nowrap"
|
||||
>
|
||||
{{ readableTime }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script>
|
||||
import Banner from '../Banner.vue';
|
||||
import Branding from 'shared/components/Branding.vue';
|
||||
import ChatHeader from '../ChatHeader.vue';
|
||||
import ChatHeaderExpanded from '../ChatHeaderExpanded.vue';
|
||||
import configMixin from '../../mixins/configMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Banner,
|
||||
Branding,
|
||||
ChatHeader,
|
||||
ChatHeaderExpanded,
|
||||
},
|
||||
mixins: [configMixin],
|
||||
data() {
|
||||
return {
|
||||
showPopoutButton: false,
|
||||
scrollPosition: 0,
|
||||
ticking: true,
|
||||
disableBranding: window.chatwootWebChannel.disableBranding || false,
|
||||
requestID: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
appConfig: 'appConfig/getAppConfig',
|
||||
availableAgents: 'agent/availableAgents',
|
||||
}),
|
||||
portal() {
|
||||
return window.chatwootWebChannel.portal;
|
||||
},
|
||||
isHeaderCollapsed() {
|
||||
if (!this.hasIntroText) {
|
||||
return true;
|
||||
}
|
||||
return !this.isOnHomeView;
|
||||
},
|
||||
hasIntroText() {
|
||||
return (
|
||||
this.channelConfig.welcomeTitle || this.channelConfig.welcomeTagline
|
||||
);
|
||||
},
|
||||
showBackButton() {
|
||||
return ['article-viewer', 'messages', 'prechat-form'].includes(
|
||||
this.$route.name
|
||||
);
|
||||
},
|
||||
isOnArticleViewer() {
|
||||
return ['article-viewer'].includes(this.$route.name);
|
||||
},
|
||||
isOnHomeView() {
|
||||
return ['home'].includes(this.$route.name);
|
||||
},
|
||||
opacityClass() {
|
||||
if (this.isHeaderCollapsed) {
|
||||
return {};
|
||||
}
|
||||
if (this.scrollPosition > 30) {
|
||||
return { 'opacity-30': true };
|
||||
}
|
||||
if (this.scrollPosition > 25) {
|
||||
return { 'opacity-40': true };
|
||||
}
|
||||
if (this.scrollPosition > 20) {
|
||||
return { 'opacity-60': true };
|
||||
}
|
||||
if (this.scrollPosition > 15) {
|
||||
return { 'opacity-80': true };
|
||||
}
|
||||
if (this.scrollPosition > 10) {
|
||||
return { 'opacity-90': true };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$el.addEventListener('scroll', this.updateScrollPosition);
|
||||
},
|
||||
unmounted() {
|
||||
this.$el.removeEventListener('scroll', this.updateScrollPosition);
|
||||
cancelAnimationFrame(this.requestID);
|
||||
},
|
||||
methods: {
|
||||
closeWindow() {
|
||||
IFrameHelper.sendMessage({ event: 'closeWindow' });
|
||||
},
|
||||
updateScrollPosition(event) {
|
||||
this.scrollPosition = event.target.scrollTop;
|
||||
if (!this.ticking) {
|
||||
this.requestID = window.requestAnimationFrame(() => {
|
||||
this.ticking = false;
|
||||
});
|
||||
|
||||
this.ticking = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-full h-full bg-n-slate-2 dark:bg-n-solid-1"
|
||||
:class="{ 'overflow-auto': isOnHomeView }"
|
||||
@keydown.esc="closeWindow"
|
||||
>
|
||||
<div class="relative flex flex-col h-full">
|
||||
<div
|
||||
:class="{
|
||||
expanded: !isHeaderCollapsed,
|
||||
collapsed: isHeaderCollapsed,
|
||||
'shadow-[0_10px_15px_-16px_rgba(50,50,93,0.08),0_4px_6px_-8px_rgba(50,50,93,0.04)]':
|
||||
isHeaderCollapsed,
|
||||
...opacityClass,
|
||||
}"
|
||||
>
|
||||
<ChatHeaderExpanded
|
||||
v-if="!isHeaderCollapsed"
|
||||
:intro-heading="appConfig.welcomeTitle || channelConfig.welcomeTitle"
|
||||
:intro-body="
|
||||
appConfig.welcomeDescription || channelConfig.welcomeTagline
|
||||
"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
/>
|
||||
<ChatHeader
|
||||
v-if="isHeaderCollapsed"
|
||||
:title="channelConfig.websiteName"
|
||||
:avatar-url="channelConfig.avatarUrl"
|
||||
:show-popout-button="appConfig.showPopoutButton"
|
||||
:available-agents="availableAgents"
|
||||
:show-back-button="showBackButton"
|
||||
/>
|
||||
</div>
|
||||
<Banner />
|
||||
<router-view />
|
||||
|
||||
<Branding v-if="!isOnArticleViewer" :disable-branding="disableBranding" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, computed } from 'vue';
|
||||
import ArticleListItem from './ArticleListItem.vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['view', 'viewAll']);
|
||||
|
||||
const widgetColor = useMapGetter('appConfig/getWidgetColor');
|
||||
|
||||
const articlesToDisplay = computed(() => props.articles.slice(0, 6));
|
||||
|
||||
const onArticleClick = link => {
|
||||
emit('view', link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<h3 class="font-medium text-n-slate-12">
|
||||
{{ $t('PORTAL.POPULAR_ARTICLES') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
<ArticleListItem
|
||||
v-for="article in articlesToDisplay"
|
||||
:key="article.slug"
|
||||
:link="article.link"
|
||||
:title="article.title"
|
||||
@select-article="onArticleClick"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="font-medium tracking-wide inline-flex"
|
||||
:style="{ color: widgetColor }"
|
||||
@click="$emit('viewAll')"
|
||||
>
|
||||
<span>{{ $t('PORTAL.VIEW_ALL_ARTICLES') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
|
||||
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useDarkMode } from 'widget/composables/useDarkMode';
|
||||
import { getMatchingLocale } from 'shared/helpers/portalHelper';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
|
||||
const portal = computed(() => window.chatwootWebChannel.portal);
|
||||
|
||||
const popularArticles = useMapGetter('article/popularArticles');
|
||||
const articleUiFlags = useMapGetter('article/uiFlags');
|
||||
|
||||
const locale = computed(() => {
|
||||
const { locale: selectedLocale } = i18n;
|
||||
|
||||
if (!portal.value || !portal.value.config) return null;
|
||||
|
||||
const { allowed_locales: allowedLocales } = portal.value.config;
|
||||
return getMatchingLocale(selectedLocale.value, allowedLocales);
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
if (portal.value && locale.value) {
|
||||
store.dispatch('article/fetch', {
|
||||
slug: portal.value.slug,
|
||||
locale: locale.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openArticleInArticleViewer = link => {
|
||||
const params = new URLSearchParams({
|
||||
show_plain_layout: 'true',
|
||||
theme: prefersDarkMode.value ? 'dark' : 'light',
|
||||
...(locale.value && { locale: locale.value }),
|
||||
});
|
||||
|
||||
// Combine link with query parameters
|
||||
const linkToOpen = `${link}?${params.toString()}`;
|
||||
router.push({ name: 'article-viewer', query: { link: linkToOpen } });
|
||||
};
|
||||
|
||||
const viewAllArticles = () => {
|
||||
const {
|
||||
portal: { slug },
|
||||
} = window.chatwootWebChannel;
|
||||
openArticleInArticleViewer(`/hc/${slug}/${locale.value}`);
|
||||
};
|
||||
|
||||
const hasArticles = computed(
|
||||
() =>
|
||||
!articleUiFlags.value.isFetching &&
|
||||
!articleUiFlags.value.isError &&
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
);
|
||||
|
||||
// Watch for locale changes and refetch articles
|
||||
watch(locale, (newLocale, oldLocale) => {
|
||||
if (newLocale && newLocale !== oldLocale) {
|
||||
fetchArticles();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="portal && (articleUiFlags.isFetching || !!popularArticles.length)"
|
||||
class="w-full shadow outline-1 outline outline-n-container rounded-xl bg-n-background dark:bg-n-solid-2 px-5 py-4"
|
||||
>
|
||||
<ArticleBlock
|
||||
v-if="hasArticles"
|
||||
:articles="popularArticles"
|
||||
@view="openArticleInArticleViewer"
|
||||
@view-all="viewAllArticles"
|
||||
/>
|
||||
<ArticleCardSkeletonLoader v-if="articleUiFlags.isFetching" />
|
||||
</div>
|
||||
<div v-else class="hidden" />
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
const props = defineProps({
|
||||
link: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['selectArticle']);
|
||||
const onClick = () => {
|
||||
emit('selectArticle', props.link);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between rounded cursor-pointer text-n-slate-11 hover:text-n-slate-12 gap-2"
|
||||
role="button"
|
||||
@click="onClick"
|
||||
>
|
||||
<button
|
||||
class="underline-offset-2 leading-6 ltr:text-left rtl:text-right text-base"
|
||||
>
|
||||
{{ title }}
|
||||
</button>
|
||||
<span class="i-lucide-chevron-right text-base shrink-0" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-6 bg-n-slate-5 dark:bg-n-alpha-black1 rounded w-2/5" />
|
||||
</div>
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded" />
|
||||
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded" />
|
||||
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded" />
|
||||
</div>
|
||||
<div class="space-y-2 animate-pulse">
|
||||
<div class="h-4 bg-n-slate-5 dark:bg-n-alpha-black1 rounded w-1/5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { truncateMessage } = useMessageFormatter();
|
||||
return { truncateMessage };
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<div
|
||||
v-if="!!items.length"
|
||||
class="chat-bubble agent bg-n-background dark:bg-n-solid-3"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.link"
|
||||
class="border-b border-solid border-n-weak text-sm py-2 px-0 last:border-b-0"
|
||||
>
|
||||
<a
|
||||
:href="item.link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
class="text-n-slate-12 no-underline"
|
||||
>
|
||||
<span class="flex items-center text-n-slate-12 font-medium">
|
||||
<FluentIcon icon="link" class="ltr:mr-1 rtl:ml-1 text-n-slate-12" />
|
||||
<span class="text-n-slate-12">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="block mt-1 text-n-slate-12">
|
||||
{{ truncateMessage(item.description) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
messageId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
messageContentAttributes: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
email: '',
|
||||
isUpdating: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
}),
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
hasSubmitted() {
|
||||
return (
|
||||
this.messageContentAttributes &&
|
||||
this.messageContentAttributes.submitted_email
|
||||
);
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onSubmit() {
|
||||
if (this.v$.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.isUpdating = true;
|
||||
try {
|
||||
await this.$store.dispatch('message/update', {
|
||||
email: this.email,
|
||||
messageId: this.messageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
} finally {
|
||||
this.isUpdating = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<form
|
||||
v-if="!hasSubmitted"
|
||||
class="email-input-group h-10 flex my-2 mx-0 min-w-[200px]"
|
||||
@submit.prevent="onSubmit"
|
||||
>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
:placeholder="$t('EMAIL_PLACEHOLDER')"
|
||||
:class="{ error: v$.email.$error }"
|
||||
@input="v$.email.$touch"
|
||||
@keydown.enter="onSubmit"
|
||||
/>
|
||||
<button
|
||||
class="button small"
|
||||
:disabled="v$.email.$invalid"
|
||||
:style="{
|
||||
background: widgetColor,
|
||||
borderColor: widgetColor,
|
||||
color: textColor,
|
||||
}"
|
||||
>
|
||||
<FluentIcon v-if="!isUpdating" icon="chevron-right" />
|
||||
<Spinner v-else class="mx-2" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.email-input-group {
|
||||
input {
|
||||
@apply dark:bg-n-alpha-black1 rtl:rounded-tl-[0] ltr:rounded-tr-[0] rtl:rounded-bl-[0] ltr:rounded-br-[0] p-2.5 w-full focus:ring-0 focus:outline-n-brand;
|
||||
|
||||
&::placeholder {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
&.error {
|
||||
@apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
@apply rtl:rounded-tr-[0] ltr:rounded-tl-[0] rtl:rounded-br-[0] ltr:rounded-bl-[0] rounded-lg h-auto ltr:-ml-px rtl:-mr-px text-xl;
|
||||
|
||||
.spinner {
|
||||
display: block;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script>
|
||||
import IntegrationAPIClient from 'widget/api/integration';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { buildDyteURL } from 'shared/helpers/IntegrationHelper';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
FluentIcon,
|
||||
},
|
||||
props: {
|
||||
messageId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { isLoading: false, dyteAuthToken: '', isSDKMounted: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ widgetColor: 'appConfig/getWidgetColor' }),
|
||||
textColor() {
|
||||
return getContrastingTextColor(this.widgetColor);
|
||||
},
|
||||
meetingLink() {
|
||||
return buildDyteURL(this.dyteAuthToken);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async joinTheCall() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const response = await IntegrationAPIClient.addParticipantToDyteMeeting(
|
||||
this.messageId
|
||||
);
|
||||
const { data: { token } = {} } = response;
|
||||
this.dyteAuthToken = token;
|
||||
} catch (error) {
|
||||
// Ignore Error for now
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
leaveTheRoom() {
|
||||
this.dyteAuthToken = '';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
class="button join-call-button"
|
||||
color-scheme="secondary"
|
||||
:is-loading="isLoading"
|
||||
:style="{
|
||||
background: widgetColor,
|
||||
borderColor: widgetColor,
|
||||
color: textColor,
|
||||
}"
|
||||
@click="joinTheCall"
|
||||
>
|
||||
<FluentIcon icon="video-add" class="rtl:ml-2 ltr:mr-2" />
|
||||
{{ $t('INTEGRATIONS.DYTE.CLICK_HERE_TO_JOIN') }}
|
||||
</button>
|
||||
<div v-if="dyteAuthToken" class="video-call--container">
|
||||
<iframe
|
||||
:src="meetingLink"
|
||||
allow="camera;microphone;fullscreen;display-capture;picture-in-picture;clipboard-write;"
|
||||
/>
|
||||
<button
|
||||
class="button small join-call-button leave-room-button"
|
||||
@click="leaveTheRoom"
|
||||
>
|
||||
{{ $t('INTEGRATIONS.DYTE.LEAVE_THE_ROOM') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.video-call--container {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
z-index: 100;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: calc(100% - 72px);
|
||||
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.join-call-button {
|
||||
@apply flex items-center my-2 rounded-lg;
|
||||
}
|
||||
|
||||
.leave-room-button {
|
||||
@apply absolute top-0 ltr:right-2 rtl:left-2 px-1 rounded-md;
|
||||
}
|
||||
</style>
|
||||
@@ -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,125 @@
|
||||
import { ref } from 'vue';
|
||||
import { useAvailability } from '../useAvailability';
|
||||
|
||||
const mockIsOnline = vi.fn();
|
||||
const mockIsInWorkingHours = vi.fn();
|
||||
const mockUseCamelCase = vi.fn(obj => obj);
|
||||
|
||||
vi.mock('widget/helpers/availabilityHelpers', () => ({
|
||||
isOnline: (...args) => mockIsOnline(...args),
|
||||
isInWorkingHours: (...args) => mockIsInWorkingHours(...args),
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables/useTransformKeys', () => ({
|
||||
useCamelCase: obj => mockUseCamelCase(obj),
|
||||
}));
|
||||
|
||||
describe('useAvailability', () => {
|
||||
const originalWindow = window.chatwootWebChannel;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset mocks to return true by default
|
||||
mockIsOnline.mockReturnValue(true);
|
||||
mockIsInWorkingHours.mockReturnValue(true);
|
||||
mockUseCamelCase.mockImplementation(obj => obj);
|
||||
|
||||
window.chatwootWebChannel = {
|
||||
workingHours: [],
|
||||
workingHoursEnabled: false,
|
||||
timezone: 'UTC',
|
||||
utcOffset: 'UTC',
|
||||
replyTime: 'in_a_few_minutes',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.chatwootWebChannel = originalWindow;
|
||||
});
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { availableAgents, hasOnlineAgents, isInWorkingHours, isOnline } =
|
||||
useAvailability();
|
||||
|
||||
expect(availableAgents.value).toEqual([]);
|
||||
expect(hasOnlineAgents.value).toBe(false);
|
||||
expect(isInWorkingHours.value).toBe(true);
|
||||
expect(isOnline.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with agents', () => {
|
||||
it('should handle agents array', () => {
|
||||
const agents = [{ id: 1 }, { id: 2 }];
|
||||
const { availableAgents, hasOnlineAgents } = useAvailability(agents);
|
||||
|
||||
expect(availableAgents.value).toEqual(agents);
|
||||
expect(hasOnlineAgents.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle reactive agents', () => {
|
||||
const agents = ref([{ id: 1 }]);
|
||||
const { hasOnlineAgents } = useAvailability(agents);
|
||||
|
||||
expect(hasOnlineAgents.value).toBe(true);
|
||||
|
||||
agents.value = [];
|
||||
expect(hasOnlineAgents.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('working hours', () => {
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
beforeEach(() => {
|
||||
window.chatwootWebChannel = {
|
||||
workingHours,
|
||||
workingHoursEnabled: true,
|
||||
utcOffset: '+05:30',
|
||||
};
|
||||
});
|
||||
|
||||
it('should check working hours', () => {
|
||||
mockIsInWorkingHours.mockReturnValueOnce(true);
|
||||
const { isInWorkingHours } = useAvailability();
|
||||
const result = isInWorkingHours.value;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIsInWorkingHours).toHaveBeenCalledWith(
|
||||
expect.any(Date),
|
||||
'+05:30',
|
||||
workingHours
|
||||
);
|
||||
});
|
||||
|
||||
it('should determine online status based on working hours and agents', () => {
|
||||
mockIsOnline.mockReturnValueOnce(true);
|
||||
const { isOnline } = useAvailability([{ id: 1 }]);
|
||||
const result = isOnline.value;
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockIsOnline).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.any(Date),
|
||||
'+05:30',
|
||||
workingHours,
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config changes', () => {
|
||||
it('should react to window.chatwootWebChannel changes', () => {
|
||||
const { inboxConfig } = useAvailability();
|
||||
|
||||
window.chatwootWebChannel = {
|
||||
...window.chatwootWebChannel,
|
||||
replyTime: 'in_a_day',
|
||||
};
|
||||
|
||||
expect(inboxConfig.value.replyTime).toBe('in_a_day');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useDarkMode } from '../useDarkMode';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useMapGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useDarkMode', () => {
|
||||
let mockDarkMode;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDarkMode = { value: 'light' };
|
||||
vi.mocked(useMapGetter).mockReturnValue(mockDarkMode);
|
||||
});
|
||||
|
||||
it('returns darkMode, prefersDarkMode', () => {
|
||||
const result = useDarkMode();
|
||||
expect(result).toHaveProperty('darkMode');
|
||||
expect(result).toHaveProperty('prefersDarkMode');
|
||||
});
|
||||
|
||||
describe('prefersDarkMode', () => {
|
||||
it('returns false when darkMode is light', () => {
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
expect(prefersDarkMode.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when darkMode is dark', () => {
|
||||
mockDarkMode.value = 'dark';
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
expect(prefersDarkMode.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when darkMode is auto and OS prefers dark mode', () => {
|
||||
mockDarkMode.value = 'auto';
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true });
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
expect(prefersDarkMode.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when darkMode is auto and OS prefers light mode', () => {
|
||||
mockDarkMode.value = 'auto';
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false });
|
||||
const { prefersDarkMode } = useDarkMode();
|
||||
expect(prefersDarkMode.value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { computed, unref } from 'vue';
|
||||
import {
|
||||
isOnline as checkIsOnline,
|
||||
isInWorkingHours as checkInWorkingHours,
|
||||
} from 'widget/helpers/availabilityHelpers';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
const DEFAULT_TIMEZONE = 'UTC';
|
||||
const DEFAULT_REPLY_TIME = 'in_a_few_minutes';
|
||||
|
||||
/**
|
||||
* Composable for availability-related logic
|
||||
* @param {Ref|Array} agents - Available agents (can be ref or raw array)
|
||||
* @returns {Object} Availability utilities and computed properties
|
||||
*/
|
||||
export function useAvailability(agents = []) {
|
||||
// Now receives toRef(props, 'agents') from caller, which maintains reactivity.
|
||||
// Use unref() inside computed to unwrap the ref value properly.
|
||||
// This ensures availableAgents updates when the parent's agents prop changes
|
||||
// (e.g., after API response updates the Vuex store).
|
||||
const availableAgents = computed(() => unref(agents));
|
||||
|
||||
const channelConfig = computed(() => window.chatwootWebChannel || {});
|
||||
|
||||
const inboxConfig = computed(() => ({
|
||||
workingHours: channelConfig.value.workingHours?.map(useCamelCase) || [],
|
||||
workingHoursEnabled: channelConfig.value.workingHoursEnabled || false,
|
||||
timezone: channelConfig.value.timezone || DEFAULT_TIMEZONE,
|
||||
utcOffset:
|
||||
channelConfig.value.utcOffset ||
|
||||
channelConfig.value.timezone ||
|
||||
DEFAULT_TIMEZONE,
|
||||
replyTime: channelConfig.value.replyTime || DEFAULT_REPLY_TIME,
|
||||
}));
|
||||
|
||||
const currentTime = computed(() => new Date());
|
||||
|
||||
const hasOnlineAgents = computed(() => {
|
||||
const agentList = availableAgents.value || [];
|
||||
return Array.isArray(agentList) ? agentList.length > 0 : false;
|
||||
});
|
||||
|
||||
const isInWorkingHours = computed(() =>
|
||||
checkInWorkingHours(
|
||||
currentTime.value,
|
||||
inboxConfig.value.utcOffset,
|
||||
inboxConfig.value.workingHours
|
||||
)
|
||||
);
|
||||
|
||||
// Check if online (considering both working hours and agents)
|
||||
const isOnline = computed(() =>
|
||||
checkIsOnline(
|
||||
inboxConfig.value.workingHoursEnabled,
|
||||
currentTime.value,
|
||||
inboxConfig.value.utcOffset,
|
||||
inboxConfig.value.workingHours,
|
||||
hasOnlineAgents.value
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
channelConfig,
|
||||
inboxConfig,
|
||||
|
||||
currentTime,
|
||||
availableAgents,
|
||||
hasOnlineAgents,
|
||||
|
||||
isOnline,
|
||||
isInWorkingHours,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const isDarkModeAuto = mode => mode === 'auto';
|
||||
const isDarkMode = mode => mode === 'dark';
|
||||
|
||||
const getSystemPreference = () =>
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
|
||||
const calculatePrefersDarkMode = (mode, systemPreference) =>
|
||||
isDarkModeAuto(mode) ? systemPreference : isDarkMode(mode);
|
||||
|
||||
/**
|
||||
* Composable for handling dark mode.
|
||||
* @returns {Object} An object containing computed properties and methods for dark mode.
|
||||
*/
|
||||
export function useDarkMode() {
|
||||
const darkMode = useMapGetter('appConfig/darkMode');
|
||||
|
||||
const systemPreference = computed(getSystemPreference);
|
||||
|
||||
const prefersDarkMode = computed(() =>
|
||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||
);
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
prefersDarkMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const SET_USER_ERROR = 'SET_USER_ERROR';
|
||||
@@ -0,0 +1,7 @@
|
||||
export const CHATWOOT_ERROR = 'chatwoot:error';
|
||||
export const CHATWOOT_ON_MESSAGE = 'chatwoot:on-message';
|
||||
export const CHATWOOT_ON_START_CONVERSATION = 'chatwoot:on-start-conversation';
|
||||
export const CHATWOOT_POSTBACK = 'chatwoot:postback';
|
||||
export const CHATWOOT_READY = 'chatwoot:ready';
|
||||
export const CHATWOOT_OPENED = 'chatwoot:opened';
|
||||
export const CHATWOOT_CLOSED = 'chatwoot:closed';
|
||||
@@ -0,0 +1,4 @@
|
||||
export const ON_AGENT_MESSAGE_RECEIVED = 'ON_AGENT_MESSAGE_RECEIVED';
|
||||
export const ON_UNREAD_MESSAGE_CLICK = 'ON_UNREAD_MESSAGE_CLICK';
|
||||
export const ON_CAMPAIGN_MESSAGE_CLICK = 'ON_CAMPAIGN_MESSAGE_CLICK';
|
||||
export const ON_CONVERSATION_CREATED = 'ON_CONVERSATION_CREATED';
|
||||
@@ -0,0 +1,46 @@
|
||||
export const loadedEventConfig = () => {
|
||||
return {
|
||||
event: 'loaded',
|
||||
config: {
|
||||
authToken: window.authToken,
|
||||
channelConfig: window.chatwootWebChannel,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getExtraSpaceToScroll = () => {
|
||||
// This function calculates the extra space needed for the view to
|
||||
// accommodate the height of close button + height of
|
||||
// read messages button. So that scrollbar won't appear
|
||||
const unreadMessageWrap = document.querySelector('.unread-messages');
|
||||
const unreadCloseWrap = document.querySelector('.close-unread-wrap');
|
||||
const readViewWrap = document.querySelector('.open-read-view-wrap');
|
||||
|
||||
if (!unreadMessageWrap) return 0;
|
||||
|
||||
// 24px to compensate the paddings
|
||||
let extraHeight = 48 + unreadMessageWrap.scrollHeight;
|
||||
if (unreadCloseWrap) extraHeight += unreadCloseWrap.scrollHeight;
|
||||
if (readViewWrap) extraHeight += readViewWrap.scrollHeight;
|
||||
|
||||
return extraHeight;
|
||||
};
|
||||
|
||||
export const shouldTriggerMessageUpdateEvent = message => {
|
||||
const { previous_changes: previousChanges } = message;
|
||||
|
||||
if (!previousChanges) {
|
||||
return false;
|
||||
}
|
||||
const hasNotifiableAttributeChanges =
|
||||
Object.keys(previousChanges).includes('content_attributes');
|
||||
if (!hasNotifiableAttributeChanges) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasSubmittedValueChanges = Object.keys(
|
||||
previousChanges.content_attributes[1] || {}
|
||||
).includes('submitted_values');
|
||||
|
||||
return hasSubmittedValueChanges;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
|
||||
export const playNewMessageNotificationInWidget = () => {
|
||||
IFrameHelper.sendMessage({ event: 'playAudio' });
|
||||
};
|
||||
142
research/chatwoot/app/javascript/widget/helpers/actionCable.js
Normal file
142
research/chatwoot/app/javascript/widget/helpers/actionCable.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import { playNewMessageNotificationInWidget } from 'widget/helpers/WidgetAudioNotificationHelper';
|
||||
import { ON_AGENT_MESSAGE_RECEIVED } from '../constants/widgetBusEvents';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { shouldTriggerMessageUpdateEvent } from './IframeEventHelper';
|
||||
import { CHATWOOT_ON_MESSAGE } from '../constants/sdkEvents';
|
||||
import { emitter } from '../../shared/helpers/mitt';
|
||||
|
||||
const isMessageInActiveConversation = (getters, message) => {
|
||||
const { conversation_id: conversationId } = message;
|
||||
const activeConversationId =
|
||||
getters['conversationAttributes/getConversationParams'].id;
|
||||
return activeConversationId && conversationId !== activeConversationId;
|
||||
};
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
super(app, pubsubToken);
|
||||
this.events = {
|
||||
'message.created': this.onMessageCreated,
|
||||
'message.updated': this.onMessageUpdated,
|
||||
'conversation.typing_on': this.onTypingOn,
|
||||
'conversation.typing_off': this.onTypingOff,
|
||||
'conversation.status_changed': this.onStatusChange,
|
||||
'conversation.created': this.onConversationCreated,
|
||||
'presence.update': this.onPresenceUpdate,
|
||||
'contact.merged': this.onContactMerge,
|
||||
};
|
||||
}
|
||||
|
||||
onDisconnected = () => {
|
||||
this.setLastMessageId();
|
||||
};
|
||||
|
||||
onReconnect = () => {
|
||||
this.syncLatestMessages();
|
||||
};
|
||||
|
||||
setLastMessageId = () => {
|
||||
this.app.$store.dispatch('conversation/setLastMessageId');
|
||||
};
|
||||
|
||||
syncLatestMessages = () => {
|
||||
this.app.$store.dispatch('conversation/syncLatestMessages');
|
||||
};
|
||||
|
||||
onStatusChange = data => {
|
||||
if (data.status === 'resolved') {
|
||||
this.app.$store.dispatch('campaign/resetCampaign');
|
||||
}
|
||||
this.app.$store.dispatch('conversationAttributes/update', data);
|
||||
};
|
||||
|
||||
onMessageCreated = data => {
|
||||
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.app.$store
|
||||
.dispatch('conversation/addOrUpdateMessage', data)
|
||||
.then(() => emitter.emit(ON_AGENT_MESSAGE_RECEIVED));
|
||||
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_MESSAGE,
|
||||
data,
|
||||
});
|
||||
if (data.sender_type === 'User') {
|
||||
playNewMessageNotificationInWidget();
|
||||
}
|
||||
};
|
||||
|
||||
onMessageUpdated = data => {
|
||||
if (isMessageInActiveConversation(this.app.$store.getters, data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldTriggerMessageUpdateEvent(data)) {
|
||||
IFrameHelper.sendMessage({
|
||||
event: 'onEvent',
|
||||
eventIdentifier: CHATWOOT_ON_MESSAGE,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
this.app.$store.dispatch('conversation/addOrUpdateMessage', data);
|
||||
};
|
||||
|
||||
onConversationCreated = () => {
|
||||
this.app.$store.dispatch('conversationAttributes/getAttributes');
|
||||
};
|
||||
|
||||
onPresenceUpdate = data => {
|
||||
this.app.$store.dispatch('agent/updatePresence', data.users);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onContactMerge = data => {
|
||||
const { pubsub_token: pubsubToken } = data;
|
||||
ActionCableConnector.refreshConnector(pubsubToken);
|
||||
};
|
||||
|
||||
onTypingOn = data => {
|
||||
const activeConversationId =
|
||||
this.app.$store.getters['conversationAttributes/getConversationParams']
|
||||
.id;
|
||||
const isUserTypingOnAnotherConversation =
|
||||
data.conversation && data.conversation.id !== activeConversationId;
|
||||
|
||||
if (isUserTypingOnAnotherConversation || data.is_private) {
|
||||
return;
|
||||
}
|
||||
this.clearTimer();
|
||||
this.app.$store.dispatch('conversation/toggleAgentTyping', {
|
||||
status: 'on',
|
||||
});
|
||||
this.initTimer();
|
||||
};
|
||||
|
||||
onTypingOff = () => {
|
||||
this.clearTimer();
|
||||
this.app.$store.dispatch('conversation/toggleAgentTyping', {
|
||||
status: 'off',
|
||||
});
|
||||
};
|
||||
|
||||
clearTimer = () => {
|
||||
if (this.CancelTyping) {
|
||||
clearTimeout(this.CancelTyping);
|
||||
this.CancelTyping = null;
|
||||
}
|
||||
};
|
||||
|
||||
initTimer = () => {
|
||||
// Turn off typing automatically after 30 seconds
|
||||
this.CancelTyping = setTimeout(() => {
|
||||
this.onTypingOff();
|
||||
}, 30000);
|
||||
};
|
||||
}
|
||||
|
||||
export default ActionCableConnector;
|
||||
@@ -0,0 +1,340 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
|
||||
// Constants
|
||||
const DAYS_IN_WEEK = 7;
|
||||
const MINUTES_IN_HOUR = 60;
|
||||
const MINUTES_IN_DAY = 24 * 60;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get date in timezone
|
||||
* @private
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @returns {Date}
|
||||
*/
|
||||
const getDateInTimezone = (time, utcOffset) => {
|
||||
const dateString = time instanceof Date ? time.toISOString() : time;
|
||||
try {
|
||||
return utcToZonedTime(dateString, utcOffset);
|
||||
} catch (error) {
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Invalid timezone: ${utcOffset}, falling back to user timezone: ${userTimezone}`
|
||||
);
|
||||
return utcToZonedTime(dateString, userTimezone);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert time to minutes
|
||||
* @private
|
||||
* @param {number} hours
|
||||
* @param {number} minutes
|
||||
* @returns {number}
|
||||
*/
|
||||
const toMinutes = (hours = 0, minutes = 0) => hours * MINUTES_IN_HOUR + minutes;
|
||||
|
||||
/**
|
||||
* Get today's config
|
||||
* @private
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
const getTodayConfig = (time, utcOffset, workingHours) => {
|
||||
const date = getDateInTimezone(time, utcOffset);
|
||||
const dayOfWeek = date.getDay();
|
||||
return workingHours.find(slot => slot.dayOfWeek === dayOfWeek) || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current time is within working range, handling midnight crossing
|
||||
* @private
|
||||
* @param {number} currentMinutes
|
||||
* @param {number} openMinutes
|
||||
* @param {number} closeMinutes
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isTimeWithinRange = (currentMinutes, openMinutes, closeMinutes) => {
|
||||
const crossesMidnight = closeMinutes <= openMinutes;
|
||||
|
||||
return crossesMidnight
|
||||
? currentMinutes >= openMinutes || currentMinutes < closeMinutes
|
||||
: currentMinutes >= openMinutes && currentMinutes < closeMinutes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a map keyed by `dayOfWeek` for all slots that are NOT closed all day.
|
||||
* @private
|
||||
*
|
||||
* @param {Array<Object>} workingHours - Full array of working-hour slot configs.
|
||||
* @returns {Map<number, Object>} Map where the key is the numeric day (0-6) and the value is the slot config.
|
||||
*/
|
||||
const getOpenDaysMap = workingHours =>
|
||||
new Map(
|
||||
(workingHours || [])
|
||||
.filter(slot => !slot.closedAllDay)
|
||||
.map(slot => [slot.dayOfWeek, slot])
|
||||
);
|
||||
|
||||
/**
|
||||
* Determine if today's slot is still upcoming.
|
||||
* @private
|
||||
* Returns an object with details if the slot is yet to open, otherwise `null`.
|
||||
*
|
||||
* @param {number} currentDay - `Date#getDay()` value (0-6) for current time.
|
||||
* @param {number} currentMinutes - Minutes since midnight for current time.
|
||||
* @param {Map<number, Object>} openDays - Map produced by `getOpenDaysMap`.
|
||||
* @returns {Object|null} Slot details (config, minutesUntilOpen, etc.) or `null`.
|
||||
*/
|
||||
const checkTodayAvailability = (currentDay, currentMinutes, openDays) => {
|
||||
const todayConfig = openDays.get(currentDay);
|
||||
if (!todayConfig || todayConfig.openAllDay) return null;
|
||||
|
||||
const todayOpenMinutes = toMinutes(
|
||||
todayConfig.openHour ?? 0,
|
||||
todayConfig.openMinutes ?? 0
|
||||
);
|
||||
|
||||
// Haven't opened yet today
|
||||
if (currentMinutes < todayOpenMinutes) {
|
||||
return {
|
||||
config: todayConfig,
|
||||
minutesUntilOpen: todayOpenMinutes - currentMinutes,
|
||||
daysUntilOpen: 0,
|
||||
dayOfWeek: currentDay,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Search the upcoming days (including tomorrow) for the next open slot.
|
||||
* @private
|
||||
*
|
||||
* @param {number} currentDay - Day index (0-6) representing today.
|
||||
* @param {number} currentMinutes - Minutes since midnight for current time.
|
||||
* @param {Map<number, Object>} openDays - Map of open day configs.
|
||||
* @returns {Object|null} Details of the next slot or `null` if none found.
|
||||
*/
|
||||
const findNextSlot = (currentDay, currentMinutes, openDays) =>
|
||||
Array.from({ length: DAYS_IN_WEEK }, (_, i) => i + 1)
|
||||
.map(daysAhead => {
|
||||
const targetDay = (currentDay + daysAhead) % DAYS_IN_WEEK;
|
||||
const config = openDays.get(targetDay);
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
// Calculate minutes until this slot opens
|
||||
const slotOpenMinutes = config.openAllDay
|
||||
? 0
|
||||
: toMinutes(config.openHour ?? 0, config.openMinutes ?? 0);
|
||||
const minutesUntilOpen =
|
||||
MINUTES_IN_DAY -
|
||||
currentMinutes + // remaining mins today
|
||||
(daysAhead - 1) * MINUTES_IN_DAY + // full days between
|
||||
slotOpenMinutes; // opening on target day
|
||||
|
||||
return {
|
||||
config,
|
||||
minutesUntilOpen,
|
||||
daysUntilOpen: daysAhead,
|
||||
dayOfWeek: targetDay,
|
||||
};
|
||||
})
|
||||
.find(Boolean) || null;
|
||||
|
||||
/**
|
||||
* Convert slot details from inbox timezone to user timezone
|
||||
* @private
|
||||
* @param {Date} time - Current time
|
||||
* @param {string} inboxTz - Inbox timezone
|
||||
* @param {Object} slotDetails - Original slot details
|
||||
* @returns {Object} Slot details with user timezone adjustments
|
||||
*/
|
||||
const convertSlotToUserTimezone = (time, inboxTz, slotDetails) => {
|
||||
if (!slotDetails) return null;
|
||||
|
||||
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// If timezones match, no conversion needed
|
||||
if (inboxTz === userTz) return slotDetails;
|
||||
|
||||
// Calculate when the slot opens (absolute time)
|
||||
const now = time instanceof Date ? time : new Date(time);
|
||||
const openingTime = new Date(
|
||||
now.getTime() + slotDetails.minutesUntilOpen * 60000
|
||||
);
|
||||
|
||||
// Convert to user timezone
|
||||
const openingInUserTz = getDateInTimezone(openingTime, userTz);
|
||||
const nowInUserTz = getDateInTimezone(now, userTz);
|
||||
|
||||
// Calculate days difference in user timezone
|
||||
const openingDate = new Date(openingInUserTz);
|
||||
openingDate.setHours(0, 0, 0, 0);
|
||||
const todayDate = new Date(nowInUserTz);
|
||||
todayDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilOpen = Math.round((openingDate - todayDate) / 86400000);
|
||||
|
||||
// Return with user timezone adjustments
|
||||
return {
|
||||
...slotDetails,
|
||||
config: {
|
||||
...slotDetails.config,
|
||||
openHour: openingInUserTz.getHours(),
|
||||
openMinutes: openingInUserTz.getMinutes(),
|
||||
dayOfWeek: openingInUserTz.getDay(),
|
||||
},
|
||||
daysUntilOpen,
|
||||
dayOfWeek: openingInUserTz.getDay(),
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if open all day
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOpenAllDay = (time, utcOffset, workingHours = []) => {
|
||||
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
|
||||
return todayConfig?.openAllDay === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if closed all day
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isClosedAllDay = (time, utcOffset, workingHours = []) => {
|
||||
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
|
||||
return todayConfig?.closedAllDay === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if in working hours
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isInWorkingHours = (time, utcOffset, workingHours = []) => {
|
||||
if (!workingHours.length) return false;
|
||||
|
||||
const todayConfig = getTodayConfig(time, utcOffset, workingHours);
|
||||
if (!todayConfig) return false;
|
||||
|
||||
// Handle all-day states
|
||||
if (todayConfig.openAllDay) return true;
|
||||
if (todayConfig.closedAllDay) return false;
|
||||
|
||||
// Check time-based availability
|
||||
const date = getDateInTimezone(time, utcOffset);
|
||||
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
|
||||
|
||||
const openMinutes = toMinutes(
|
||||
todayConfig.openHour ?? 0,
|
||||
todayConfig.openMinutes ?? 0
|
||||
);
|
||||
const closeMinutes = toMinutes(
|
||||
todayConfig.closeHour ?? 0,
|
||||
todayConfig.closeMinutes ?? 0
|
||||
);
|
||||
|
||||
return isTimeWithinRange(currentMinutes, openMinutes, closeMinutes);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find next available slot with detailed information
|
||||
* Returns times adjusted to user's timezone for display
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
export const findNextAvailableSlotDetails = (
|
||||
time,
|
||||
utcOffset,
|
||||
workingHours = []
|
||||
) => {
|
||||
const date = getDateInTimezone(time, utcOffset);
|
||||
const currentDay = date.getDay();
|
||||
const currentMinutes = toMinutes(date.getHours(), date.getMinutes());
|
||||
|
||||
const openDays = getOpenDaysMap(workingHours);
|
||||
|
||||
// No open days at all
|
||||
if (openDays.size === 0) return null;
|
||||
|
||||
// Check today first
|
||||
const todaySlot = checkTodayAvailability(
|
||||
currentDay,
|
||||
currentMinutes,
|
||||
openDays
|
||||
);
|
||||
|
||||
// Find the slot (today or next)
|
||||
const slotDetails =
|
||||
todaySlot || findNextSlot(currentDay, currentMinutes, openDays);
|
||||
|
||||
// Convert to user timezone for display
|
||||
return convertSlotToUserTimezone(time, utcOffset, slotDetails);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find minutes until next available slot
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export const findNextAvailableSlotDiff = (
|
||||
time,
|
||||
utcOffset,
|
||||
workingHours = []
|
||||
) => {
|
||||
if (isInWorkingHours(time, utcOffset, workingHours)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const nextSlot = findNextAvailableSlotDetails(time, utcOffset, workingHours);
|
||||
return nextSlot ? nextSlot.minutesUntilOpen : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if online
|
||||
* @param {boolean} workingHoursEnabled
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
* @param {boolean} hasOnlineAgents
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isOnline = (
|
||||
workingHoursEnabled,
|
||||
time,
|
||||
utcOffset,
|
||||
workingHours,
|
||||
hasOnlineAgents
|
||||
) => {
|
||||
if (!workingHoursEnabled) {
|
||||
return hasOnlineAgents;
|
||||
}
|
||||
|
||||
const inWorkingHours = isInWorkingHours(time, utcOffset, workingHours);
|
||||
return inWorkingHours && hasOnlineAgents;
|
||||
};
|
||||
15
research/chatwoot/app/javascript/widget/helpers/axios.js
Executable file
15
research/chatwoot/app/javascript/widget/helpers/axios.js
Executable file
@@ -0,0 +1,15 @@
|
||||
import axios from 'axios';
|
||||
import { APP_BASE_URL } from 'widget/helpers/constants';
|
||||
|
||||
export const API = axios.create({
|
||||
baseURL: APP_BASE_URL,
|
||||
withCredentials: false,
|
||||
});
|
||||
|
||||
export const setHeader = (value, key = 'X-Auth-Token') => {
|
||||
API.defaults.headers.common[key] = value;
|
||||
};
|
||||
|
||||
export const removeHeader = key => {
|
||||
delete API.defaults.headers.common[key];
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { URLPattern } from 'urlpattern-polyfill';
|
||||
|
||||
export const isPatternMatchingWithURL = (urlPattern, url) => {
|
||||
let updatedUrlPattern = urlPattern;
|
||||
const locationObj = new URL(url);
|
||||
|
||||
if (updatedUrlPattern.endsWith('/')) {
|
||||
updatedUrlPattern = updatedUrlPattern.slice(0, -1) + '*\\?*\\#*';
|
||||
}
|
||||
|
||||
if (locationObj.pathname.endsWith('/')) {
|
||||
locationObj.pathname = locationObj.pathname.slice(0, -1);
|
||||
}
|
||||
|
||||
const pattern = new URLPattern(updatedUrlPattern);
|
||||
return pattern.test(locationObj.toString());
|
||||
};
|
||||
|
||||
// Format all campaigns
|
||||
export const formatCampaigns = ({ campaigns }) => {
|
||||
return campaigns.map(item => {
|
||||
return {
|
||||
id: item.id,
|
||||
triggerOnlyDuringBusinessHours:
|
||||
item.trigger_only_during_business_hours || false,
|
||||
timeOnPage: item?.trigger_rules?.time_on_page,
|
||||
url: item?.trigger_rules?.url,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Filter all campaigns based on current URL and business availability time
|
||||
export const filterCampaigns = ({
|
||||
campaigns,
|
||||
currentURL,
|
||||
isInBusinessHours,
|
||||
}) => {
|
||||
return campaigns.filter(campaign => {
|
||||
if (!isPatternMatchingWithURL(campaign.url, currentURL)) {
|
||||
return false;
|
||||
}
|
||||
if (campaign.triggerOnlyDuringBusinessHours) {
|
||||
return isInBusinessHours;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import store from '../store';
|
||||
class CampaignTimer {
|
||||
constructor() {
|
||||
this.campaignTimers = [];
|
||||
}
|
||||
|
||||
initTimers = ({ campaigns }, websiteToken) => {
|
||||
this.clearTimers();
|
||||
campaigns.forEach(campaign => {
|
||||
const { timeOnPage, id: campaignId } = campaign;
|
||||
this.campaignTimers[campaignId] = setTimeout(() => {
|
||||
store.dispatch('campaign/startCampaign', { campaignId, websiteToken });
|
||||
}, timeOnPage * 1000);
|
||||
});
|
||||
};
|
||||
|
||||
clearTimers = () => {
|
||||
this.campaignTimers.forEach(timerId => {
|
||||
clearTimeout(timerId);
|
||||
this.campaignTimers[timerId] = null;
|
||||
});
|
||||
};
|
||||
}
|
||||
export default new CampaignTimer();
|
||||
16
research/chatwoot/app/javascript/widget/helpers/constants.js
Executable file
16
research/chatwoot/app/javascript/widget/helpers/constants.js
Executable file
@@ -0,0 +1,16 @@
|
||||
export const APP_BASE_URL = '';
|
||||
|
||||
export const MESSAGE_STATUS = {
|
||||
FAILED: 'failed',
|
||||
SUCCESS: 'success',
|
||||
PROGRESS: 'progress',
|
||||
};
|
||||
|
||||
export const MESSAGE_TYPE = {
|
||||
INCOMING: 0,
|
||||
OUTGOING: 1,
|
||||
ACTIVITY: 2,
|
||||
TEMPLATE: 3,
|
||||
};
|
||||
|
||||
export const WOOT_PREFIX = 'chatwoot-widget:';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { buildPopoutURL } from './urlParamsHelper';
|
||||
|
||||
export const popoutChatWindow = (
|
||||
origin,
|
||||
websiteToken,
|
||||
locale,
|
||||
conversationCookie
|
||||
) => {
|
||||
try {
|
||||
const windowUrl = buildPopoutURL({
|
||||
origin,
|
||||
websiteToken,
|
||||
locale,
|
||||
conversationCookie,
|
||||
});
|
||||
const popoutWindow = window.open(
|
||||
windowUrl,
|
||||
`webwidget_session_${websiteToken}`,
|
||||
'resizable=off,width=400,height=600'
|
||||
);
|
||||
popoutWindow.focus();
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,580 @@
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import {
|
||||
isOpenAllDay,
|
||||
isClosedAllDay,
|
||||
isInWorkingHours,
|
||||
findNextAvailableSlotDetails,
|
||||
findNextAvailableSlotDiff,
|
||||
isOnline,
|
||||
} from '../availabilityHelpers';
|
||||
|
||||
// Mock date-fns-tz
|
||||
vi.mock('date-fns-tz', () => ({
|
||||
utcToZonedTime: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('availabilityHelpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('isOpenAllDay', () => {
|
||||
it('should return true when slot is marked as open_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z'); // Monday
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
|
||||
|
||||
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when slot is not open_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17, openAllDay: false },
|
||||
];
|
||||
|
||||
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no config exists for the day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Tuesday config
|
||||
];
|
||||
|
||||
expect(isOpenAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isClosedAllDay', () => {
|
||||
it('should return true when slot is marked as closed_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, closedAllDay: true }];
|
||||
|
||||
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when slot is not closed_all_day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17, closedAllDay: false },
|
||||
];
|
||||
|
||||
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when no config exists for the day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 2, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isClosedAllDay(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInWorkingHours', () => {
|
||||
it('should return false when no working hours are configured', () => {
|
||||
expect(isInWorkingHours(new Date(), 'UTC', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when open_all_day is true', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when closed_all_day is true', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, closedAllDay: true }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when current time is within working hours', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 0,
|
||||
closeHour: 17,
|
||||
closeMinutes: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when current time is before opening', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when current time is after closing', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle minutes in time comparison', () => {
|
||||
const mockDate = new Date('2024-01-15T09:30:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(9);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(30);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{
|
||||
dayOfWeek: 1,
|
||||
openHour: 9,
|
||||
openMinutes: 15,
|
||||
closeHour: 17,
|
||||
closeMinutes: 30,
|
||||
},
|
||||
];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no config for current day', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 }, // Only Tuesday
|
||||
];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextAvailableSlotDetails', () => {
|
||||
it('should return null when no open days exist', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 0, closedAllDay: true },
|
||||
{ dayOfWeek: 1, closedAllDay: true },
|
||||
{ dayOfWeek: 2, closedAllDay: true },
|
||||
{ dayOfWeek: 3, closedAllDay: true },
|
||||
{ dayOfWeek: 4, closedAllDay: true },
|
||||
{ dayOfWeek: 5, closedAllDay: true },
|
||||
{ dayOfWeek: 6, closedAllDay: true },
|
||||
];
|
||||
|
||||
expect(
|
||||
findNextAvailableSlotDetails(new Date(), 'UTC', workingHours)
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('should return today slot when not opened yet', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, openMinutes: 30, closeHour: 17 },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[0],
|
||||
minutesUntilOpen: 90, // 1.5 hours = 90 minutes
|
||||
daysUntilOpen: 0,
|
||||
dayOfWeek: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return tomorrow slot when today is past closing', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[1],
|
||||
minutesUntilOpen: 900, // 15 hours = 900 minutes
|
||||
daysUntilOpen: 1,
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip closed days and find next open day', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z'); // Monday evening
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
|
||||
{ dayOfWeek: 2, closedAllDay: true },
|
||||
{ dayOfWeek: 3, closedAllDay: true },
|
||||
{ dayOfWeek: 4, openHour: 10, closeHour: 16 },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
// Monday 18:00 to Thursday 10:00
|
||||
// Rest of Monday: 6 hours (18:00 to 24:00) = 360 minutes
|
||||
// Tuesday: 24 hours = 1440 minutes
|
||||
// Wednesday: 24 hours = 1440 minutes
|
||||
// Thursday morning: 10 hours = 600 minutes
|
||||
// Total: 360 + 1440 + 1440 + 600 = 3840 minutes
|
||||
expect(result).toEqual({
|
||||
config: workingHours[3],
|
||||
minutesUntilOpen: 3840, // 64 hours = 3840 minutes
|
||||
daysUntilOpen: 3,
|
||||
dayOfWeek: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle open_all_day slots', () => {
|
||||
const mockDate = new Date('2024-01-15T18:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 },
|
||||
{ dayOfWeek: 2, openAllDay: true },
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[1],
|
||||
minutesUntilOpen: 360, // 6 hours to midnight = 360 minutes
|
||||
daysUntilOpen: 1,
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap around week correctly', () => {
|
||||
const mockDate = new Date('2024-01-20T18:00:00.000Z'); // Saturday evening
|
||||
mockDate.getDay = vi.fn().mockReturnValue(6);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(18);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openHour: 9, closeHour: 17 }, // Monday
|
||||
];
|
||||
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
// Saturday 18:00 to Monday 9:00
|
||||
// Rest of Saturday: 6 hours = 360 minutes
|
||||
// Sunday: 24 hours = 1440 minutes
|
||||
// Monday morning: 9 hours = 540 minutes
|
||||
// Total: 360 + 1440 + 540 = 2340 minutes
|
||||
expect(result).toEqual({
|
||||
config: workingHours[0],
|
||||
minutesUntilOpen: 2340, // 39 hours = 2340 minutes
|
||||
daysUntilOpen: 2,
|
||||
dayOfWeek: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle today open_all_day correctly', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 1, openAllDay: true },
|
||||
{ dayOfWeek: 2, openHour: 9, closeHour: 17 },
|
||||
];
|
||||
|
||||
// Should skip today since it's open_all_day and look for next slot
|
||||
const result = findNextAvailableSlotDetails(
|
||||
new Date(),
|
||||
'UTC',
|
||||
workingHours
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
config: workingHours[1],
|
||||
minutesUntilOpen: 1380, // Rest of today + 9 hours tomorrow = 1380 minutes
|
||||
daysUntilOpen: 1,
|
||||
dayOfWeek: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextAvailableSlotDiff', () => {
|
||||
it('should return 0 when currently in working hours', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('should return minutes until next slot when not in working hours', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
|
||||
60
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no next slot available', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [
|
||||
{ dayOfWeek: 0, closedAllDay: true },
|
||||
{ dayOfWeek: 1, closedAllDay: true },
|
||||
{ dayOfWeek: 2, closedAllDay: true },
|
||||
{ dayOfWeek: 3, closedAllDay: true },
|
||||
{ dayOfWeek: 4, closedAllDay: true },
|
||||
{ dayOfWeek: 5, closedAllDay: true },
|
||||
{ dayOfWeek: 6, closedAllDay: true },
|
||||
];
|
||||
|
||||
expect(findNextAvailableSlotDiff(new Date(), 'UTC', workingHours)).toBe(
|
||||
null
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOnline', () => {
|
||||
it('should return agent status when working hours disabled', () => {
|
||||
expect(isOnline(false, new Date(), 'UTC', [], true)).toBe(true);
|
||||
expect(isOnline(false, new Date(), 'UTC', [], false)).toBe(false);
|
||||
});
|
||||
|
||||
it('should check both working hours and agents when enabled', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
// In working hours + agents available = online
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true);
|
||||
|
||||
// In working hours but no agents = offline
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when outside working hours even with agents', () => {
|
||||
const mockDate = new Date('2024-01-15T08:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(8);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle open_all_day with agents', () => {
|
||||
const mockDate = new Date('2024-01-15T02:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(2);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openAllDay: true }];
|
||||
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, true)).toBe(true);
|
||||
expect(isOnline(true, new Date(), 'UTC', workingHours, false)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle string date input', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(
|
||||
isOnline(true, '2024-01-15T10:00:00.000Z', 'UTC', workingHours, true)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Timezone handling', () => {
|
||||
it('should correctly handle different timezones', () => {
|
||||
const mockDate = new Date('2024-01-15T15:30:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(15);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(30);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'Asia/Kolkata', workingHours)).toBe(
|
||||
true
|
||||
);
|
||||
expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'Asia/Kolkata'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle UTC offset format', () => {
|
||||
const mockDate = new Date('2024-01-15T10:00:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(10);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), '+05:30', workingHours)).toBe(true);
|
||||
expect(vi.mocked(utcToZonedTime)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'+05:30'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle working hours at exact boundaries', () => {
|
||||
// Test at exact opening time
|
||||
const mockDate1 = new Date('2024-01-15T09:00:00.000Z');
|
||||
mockDate1.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate1.getHours = vi.fn().mockReturnValue(9);
|
||||
mockDate1.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate1);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
|
||||
// Test at exact closing time
|
||||
const mockDate2 = new Date('2024-01-15T17:00:00.000Z');
|
||||
mockDate2.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate2.getHours = vi.fn().mockReturnValue(17);
|
||||
mockDate2.getMinutes = vi.fn().mockReturnValue(0);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate2);
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle one minute before closing', () => {
|
||||
const mockDate = new Date('2024-01-15T16:59:00.000Z');
|
||||
mockDate.getDay = vi.fn().mockReturnValue(1);
|
||||
mockDate.getHours = vi.fn().mockReturnValue(16);
|
||||
mockDate.getMinutes = vi.fn().mockReturnValue(59);
|
||||
vi.mocked(utcToZonedTime).mockReturnValue(mockDate);
|
||||
|
||||
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
|
||||
|
||||
expect(isInWorkingHours(new Date(), 'UTC', workingHours)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export default [
|
||||
{
|
||||
id: 1,
|
||||
trigger_only_during_business_hours: false,
|
||||
trigger_rules: {
|
||||
time_on_page: 3,
|
||||
url: 'https://www.chatwoot.com/pricing',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
trigger_only_during_business_hours: false,
|
||||
trigger_rules: {
|
||||
time_on_page: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
formatCampaigns,
|
||||
filterCampaigns,
|
||||
isPatternMatchingWithURL,
|
||||
} from '../campaignHelper';
|
||||
import campaigns from './campaignFixtures';
|
||||
|
||||
global.chatwootWebChannel = {
|
||||
workingHoursEnabled: false,
|
||||
};
|
||||
describe('#Campaigns Helper', () => {
|
||||
describe('#isPatternMatchingWithURL', () => {
|
||||
it('returns correct value if a valid URL is passed', () => {
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://chatwoot.com/pricing*',
|
||||
'https://chatwoot.com/pricing/'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://*.chatwoot.com/pricing/',
|
||||
'https://app.chatwoot.com/pricing/'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://{*.}?chatwoot.com/pricing?test=true',
|
||||
'https://app.chatwoot.com/pricing/?test=true'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isPatternMatchingWithURL(
|
||||
'https://{*.}?chatwoot.com/pricing*\\?*',
|
||||
'https://chatwoot.com/pricing/?test=true'
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCampaigns', () => {
|
||||
it('should return formatted campaigns if campaigns are passed', () => {
|
||||
expect(formatCampaigns({ campaigns })).toStrictEqual([
|
||||
{
|
||||
id: 1,
|
||||
timeOnPage: 3,
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
url: 'https://www.chatwoot.com/pricing',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
timeOnPage: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('filterCampaigns', () => {
|
||||
it('should return filtered campaigns if formatted campaigns are passed', () => {
|
||||
expect(
|
||||
filterCampaigns({
|
||||
campaigns: [
|
||||
{
|
||||
id: 1,
|
||||
timeOnPage: 3,
|
||||
url: 'https://www.chatwoot.com/pricing',
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
timeOnPage: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
},
|
||||
],
|
||||
currentURL: 'https://www.chatwoot.com/about/',
|
||||
})
|
||||
).toStrictEqual([
|
||||
{
|
||||
id: 2,
|
||||
timeOnPage: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should return filtered campaigns if formatted campaigns are passed and business hours enabled', () => {
|
||||
expect(
|
||||
filterCampaigns({
|
||||
campaigns: [
|
||||
{
|
||||
id: 1,
|
||||
timeOnPage: 3,
|
||||
url: 'https://www.chatwoot.com/pricing',
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
timeOnPage: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
triggerOnlyDuringBusinessHours: true,
|
||||
},
|
||||
],
|
||||
currentURL: 'https://www.chatwoot.com/about/',
|
||||
isInBusinessHours: true,
|
||||
})
|
||||
).toStrictEqual([
|
||||
{
|
||||
id: 2,
|
||||
timeOnPage: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
triggerOnlyDuringBusinessHours: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should return empty campaigns if formatted campaigns are passed and business hours disabled', () => {
|
||||
expect(
|
||||
filterCampaigns({
|
||||
campaigns: [
|
||||
{
|
||||
id: 1,
|
||||
timeOnPage: 3,
|
||||
url: 'https://www.chatwoot.com/pricing',
|
||||
triggerOnlyDuringBusinessHours: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
timeOnPage: 6,
|
||||
url: 'https://www.chatwoot.com/about',
|
||||
triggerOnlyDuringBusinessHours: true,
|
||||
},
|
||||
],
|
||||
currentURL: 'https://www.chatwoot.com/about/',
|
||||
isInBusinessHours: false,
|
||||
})
|
||||
).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
buildSearchParamsWithLocale,
|
||||
getLocale,
|
||||
buildPopoutURL,
|
||||
} from '../urlParamsHelper';
|
||||
|
||||
describe('#buildSearchParamsWithLocale', () => {
|
||||
it('returns correct search params', () => {
|
||||
let windowSpy = vi.spyOn(window, 'window', 'get');
|
||||
windowSpy.mockImplementation(() => ({
|
||||
WOOT_WIDGET: {
|
||||
$root: {
|
||||
$i18n: {
|
||||
locale: 'el',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
expect(buildSearchParamsWithLocale('?test=1234')).toEqual(
|
||||
'?test=1234&locale=el'
|
||||
);
|
||||
expect(buildSearchParamsWithLocale('')).toEqual('?locale=el');
|
||||
windowSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getLocale', () => {
|
||||
it('returns correct locale', () => {
|
||||
expect(getLocale('?test=1&cw_conv=2&locale=fr')).toEqual('fr');
|
||||
expect(getLocale('?test=1&locale=fr')).toEqual('fr');
|
||||
expect(getLocale('?test=1&cw_conv=2&website_token=3&locale=fr')).toEqual(
|
||||
'fr'
|
||||
);
|
||||
expect(getLocale('')).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#buildPopoutURL', () => {
|
||||
it('returns popout URL', () => {
|
||||
expect(
|
||||
buildPopoutURL({
|
||||
origin: 'https://chatwoot.com',
|
||||
conversationCookie: 'random-jwt-token',
|
||||
websiteToken: 'random-website-token',
|
||||
locale: 'ar',
|
||||
})
|
||||
).toEqual(
|
||||
'https://chatwoot.com/widget?cw_conversation=random-jwt-token&website_token=random-website-token&locale=ar'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IFrameHelper } from '../utils';
|
||||
|
||||
vi.mock('vue', () => ({
|
||||
config: {
|
||||
lang: 'el',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('#IFrameHelper', () => {
|
||||
describe('#isAValidEvent', () => {
|
||||
it('returns if the event is valid', () => {
|
||||
expect(
|
||||
IFrameHelper.isAValidEvent({
|
||||
data: 'chatwoot-widget:{"event":"config-set","locale":"fr","position":"left","hideMessageBubble":false,"showPopoutButton":true}',
|
||||
})
|
||||
).toEqual(true);
|
||||
expect(
|
||||
IFrameHelper.isAValidEvent({
|
||||
data: '{"event":"config-set","locale":"fr","position":"left","hideMessageBubble":false,"showPopoutButton":true}',
|
||||
})
|
||||
).toEqual(false);
|
||||
});
|
||||
});
|
||||
describe('#getMessage', () => {
|
||||
it('returns parsed message', () => {
|
||||
expect(
|
||||
IFrameHelper.getMessage({
|
||||
data: 'chatwoot-widget:{"event":"config-set","locale":"fr","position":"left","hideMessageBubble":false,"showPopoutButton":true}',
|
||||
})
|
||||
).toEqual({
|
||||
event: 'config-set',
|
||||
locale: 'fr',
|
||||
position: 'left',
|
||||
hideMessageBubble: false,
|
||||
showPopoutButton: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export const buildSearchParamsWithLocale = search => {
|
||||
// [TODO] for now this works, but we will need to find a way to get the locale from the root component
|
||||
const locale = window.WOOT_WIDGET.$root.$i18n.locale;
|
||||
const params = new URLSearchParams(search);
|
||||
params.append('locale', locale);
|
||||
|
||||
return `?${params}`;
|
||||
};
|
||||
|
||||
export const getLocale = (search = '') => {
|
||||
return new URLSearchParams(search).get('locale');
|
||||
};
|
||||
|
||||
export const buildPopoutURL = ({
|
||||
origin,
|
||||
conversationCookie,
|
||||
websiteToken,
|
||||
locale,
|
||||
}) => {
|
||||
const popoutUrl = new URL('/widget', origin);
|
||||
popoutUrl.searchParams.append('cw_conversation', conversationCookie);
|
||||
popoutUrl.searchParams.append('website_token', websiteToken);
|
||||
popoutUrl.searchParams.append('locale', locale);
|
||||
|
||||
return popoutUrl.toString();
|
||||
};
|
||||
38
research/chatwoot/app/javascript/widget/helpers/utils.js
Executable file
38
research/chatwoot/app/javascript/widget/helpers/utils.js
Executable file
@@ -0,0 +1,38 @@
|
||||
import { WOOT_PREFIX } from './constants';
|
||||
|
||||
export const isEmptyObject = obj => {
|
||||
if (!obj) return true;
|
||||
return Object.keys(obj).length === 0 && obj.constructor === Object;
|
||||
};
|
||||
|
||||
export const sendMessage = msg => {
|
||||
window.parent.postMessage(
|
||||
`chatwoot-widget:${JSON.stringify({ ...msg })}`,
|
||||
'*'
|
||||
);
|
||||
};
|
||||
|
||||
export const IFrameHelper = {
|
||||
isIFrame: () => window.self !== window.top,
|
||||
sendMessage,
|
||||
isAValidEvent: e => {
|
||||
const isDataAString = typeof e.data === 'string';
|
||||
return isDataAString && e.data.indexOf(WOOT_PREFIX) === 0;
|
||||
},
|
||||
getMessage: e => JSON.parse(e.data.replace(WOOT_PREFIX, '')),
|
||||
};
|
||||
export const RNHelper = {
|
||||
isRNWebView: () => window.ReactNativeWebView,
|
||||
sendMessage: msg => {
|
||||
window.ReactNativeWebView.postMessage(
|
||||
`chatwoot-widget:${JSON.stringify({ ...msg })}`
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const groupBy = (array, predicate) => {
|
||||
return array.reduce((acc, value) => {
|
||||
(acc[predicate(value)] ||= []).push(value);
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
10
research/chatwoot/app/javascript/widget/helpers/uuid.js
Normal file
10
research/chatwoot/app/javascript/widget/helpers/uuid.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const getUuid = () =>
|
||||
'xxxxxxxx4xxx'.replace(/[xy]/g, c => {
|
||||
// eslint-disable-next-line
|
||||
const r = (Math.random() * 16) | 0;
|
||||
// eslint-disable-next-line
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
|
||||
export default getUuid;
|
||||
83
research/chatwoot/app/javascript/widget/i18n/index.js
Normal file
83
research/chatwoot/app/javascript/widget/i18n/index.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import ar from './locale/ar.json';
|
||||
import bg from './locale/bg.json';
|
||||
import ca from './locale/ca.json';
|
||||
import cs from './locale/cs.json';
|
||||
import da from './locale/da.json';
|
||||
import de from './locale/de.json';
|
||||
import el from './locale/el.json';
|
||||
import en from './locale/en.json';
|
||||
import es from './locale/es.json';
|
||||
import fa from './locale/fa.json';
|
||||
import fi from './locale/fi.json';
|
||||
import fr from './locale/fr.json';
|
||||
import he from './locale/he.json';
|
||||
import hi from './locale/hi.json';
|
||||
import hu from './locale/hu.json';
|
||||
import id from './locale/id.json';
|
||||
import is from './locale/is.json';
|
||||
import it from './locale/it.json';
|
||||
import ja from './locale/ja.json';
|
||||
import ko from './locale/ko.json';
|
||||
import lt from './locale/lt.json';
|
||||
import lv from './locale/lv.json';
|
||||
import ml from './locale/ml.json';
|
||||
import nl from './locale/nl.json';
|
||||
import no from './locale/no.json';
|
||||
import pl from './locale/pl.json';
|
||||
import pt from './locale/pt.json';
|
||||
import pt_BR from './locale/pt_BR.json';
|
||||
import ro from './locale/ro.json';
|
||||
import ru from './locale/ru.json';
|
||||
import sk from './locale/sk.json';
|
||||
import sr from './locale/sr.json';
|
||||
import sv from './locale/sv.json';
|
||||
import ta from './locale/ta.json';
|
||||
import th from './locale/th.json';
|
||||
import tr from './locale/tr.json';
|
||||
import uk from './locale/uk.json';
|
||||
import vi from './locale/vi.json';
|
||||
import zh_CN from './locale/zh_CN.json';
|
||||
import zh_TW from './locale/zh_TW.json';
|
||||
|
||||
export default {
|
||||
ar,
|
||||
bg,
|
||||
ca,
|
||||
cs,
|
||||
da,
|
||||
de,
|
||||
el,
|
||||
en,
|
||||
es,
|
||||
fa,
|
||||
fi,
|
||||
fr,
|
||||
he,
|
||||
hi,
|
||||
hu,
|
||||
id,
|
||||
is,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
lt,
|
||||
lv,
|
||||
ml,
|
||||
nl,
|
||||
no,
|
||||
pl,
|
||||
pt_BR,
|
||||
pt,
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
ta,
|
||||
th,
|
||||
tr,
|
||||
uk,
|
||||
vi,
|
||||
zh_CN,
|
||||
zh_TW,
|
||||
};
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/am.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/am.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Submit"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
"CLOSE_MESSAGES_BUTTON": "Close",
|
||||
"COMPANY_FROM": "from",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat with us"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Please enter your email",
|
||||
"CHAT_PLACEHOLDER": "Type your message",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full Name",
|
||||
"PLACEHOLDER": "Please enter your full name",
|
||||
"REQUIRED_ERROR": "Full Name is required"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter your email address",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "No results found",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter your message",
|
||||
"ERROR": "Message too short"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Invalid field"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Request a conversation transcript",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Picture message"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio message"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video message"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "File Attachment"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Location"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "has shared a url"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/ar.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/ar.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "تحميل",
|
||||
"UPLOADING": "جاري الرفع..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "إرسال"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "إرسال الرسالة مرة أخرى",
|
||||
"ERROR_MESSAGE": "تعذر الإرسال! حاول مرة أخرى"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "غير متاح"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "متواجدون لخدمتك",
|
||||
"OFFLINE": "نحن بعيدون في الوقت الحالي",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "سوف نعود في أقرب وقت ممكن"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "عادة نقوم بالرد خلال بضع دقائق",
|
||||
"IN_A_FEW_HOURS": "عادة نقوم بالرد خلال بضع ساعات",
|
||||
"IN_A_DAY": "عادة نقوم بالرد خلال يوم واحد",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "سنكون متاحين غدًا للرد على استفساراتك",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "ابدأ المحادثة",
|
||||
"END_CONVERSATION": "إنهاء المحادثة",
|
||||
"CONTINUE_CONVERSATION": "متابعة المحادثة",
|
||||
"YOU": "أنت",
|
||||
"START_NEW_CONVERSATION": "ابدأ محادثة جديدة",
|
||||
"VIEW_UNREAD_MESSAGES": "لديك رسائل غير مقروءة",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "عرض الرسائل الجديدة",
|
||||
"CLOSE_MESSAGES_BUTTON": "أغلق",
|
||||
"COMPANY_FROM": "من",
|
||||
"BOT": "رد آلي"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "تحدث الينا"
|
||||
},
|
||||
"POWERED_BY": "مدعوم بواسطة Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "الرجاء إدخال بريدك الإلكتروني",
|
||||
"CHAT_PLACEHOLDER": "أكتب رسالتك",
|
||||
"TODAY": "اليوم",
|
||||
"YESTERDAY": "الأمس",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "الاسم الكامل",
|
||||
"PLACEHOLDER": "الرجاء إدخال اسمك الكامل",
|
||||
"REQUIRED_ERROR": "الاسم الكامل مطلوب"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "عنوان البريد الإلكتروني",
|
||||
"PLACEHOLDER": "الرجاء إدخال عنوان بريد إلكتروني صحيح",
|
||||
"REQUIRED_ERROR": "عنوان البريد الإلكتروني مطلوب",
|
||||
"VALID_ERROR": "الرجاء إدخال عنوان بريد إلكتروني صحيح"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "رقم الهاتف",
|
||||
"PLACEHOLDER": "الرجاء إدخال رقم الهاتف الخاص بك",
|
||||
"REQUIRED_ERROR": "رقم الهاتف مطلوب",
|
||||
"DIAL_CODE_VALID_ERROR": "الرجاء اختيار رمز الدولة",
|
||||
"VALID_ERROR": "يرجى إدخال رقم هاتف صحيح",
|
||||
"DROPDOWN_EMPTY": "لم يتم العثور على النتائج",
|
||||
"DROPDOWN_SEARCH": "البحث عن بلد"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "رسالة",
|
||||
"PLACEHOLDER": "يرجى إدخال رسالتك",
|
||||
"ERROR": "رسالة قصيرة جداً"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "الرجاء تقديم اسمك و بريدك الإلكتروني قبل بدء المحادثة",
|
||||
"IS_REQUIRED": "مطلوب",
|
||||
"REQUIRED": "مطلوب",
|
||||
"REGEX_ERROR": "الرجاء تقديم مدخل صحيح"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "حجم الملف يتجاوز حد الاقصى وهو {MAXIMUM_FILE_UPLOAD_SIZE}",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "حقل غير صالح"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "ابحث في الايموجي",
|
||||
"NOT_FOUND": "لا يوجد إيموجي يطابق بحثك",
|
||||
"ARIA_LABEL": "أداة اختيار الايموجي"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "قيم محادثتك",
|
||||
"SUBMITTED_TITLE": "شكرا لك على تقييم المحادثة",
|
||||
"PLACEHOLDER": "أخبرنا المزيد..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "طلب نسخة محادثة النص",
|
||||
"SEND_EMAIL_SUCCESS": "تم إرسال نص المحادثة بنجاح",
|
||||
"SEND_EMAIL_ERROR": "حدث خطأ، الرجاء المحاولة مرة أخرى"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "انقر هنا للانضمام",
|
||||
"LEAVE_THE_ROOM": "ترك المكالمة"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "المقالات الشائعة",
|
||||
"VIEW_ALL_ARTICLES": "عرض جميع المقالات",
|
||||
"IFRAME_LOAD_ERROR": "حدث خطأ أثناء جلب المقال، الرجاء تحديث الصفحة والمحاولة مرة أخرى."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "رسالة صورة"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "رسالة صوتية"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "رسالة فيديو"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "مرفقات"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "الموقع الجغرافي"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "قام بمشاركة رابط"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "الرد على:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/az.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/az.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Submit"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
"CLOSE_MESSAGES_BUTTON": "Close",
|
||||
"COMPANY_FROM": "from",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat with us"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Please enter your email",
|
||||
"CHAT_PLACEHOLDER": "Type your message",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full Name",
|
||||
"PLACEHOLDER": "Please enter your full name",
|
||||
"REQUIRED_ERROR": "Full Name is required"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter your email address",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "No results found",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter your message",
|
||||
"ERROR": "Message too short"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Invalid field"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Request a conversation transcript",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Picture message"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio message"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video message"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "File Attachment"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Location"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "has shared a url"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/bg.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/bg.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Изтегляне",
|
||||
"UPLOADING": "Качване..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Изпращане"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "На линия сме",
|
||||
"OFFLINE": "В момента не сме на линия",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Обикновено отговаряме до няколко минути",
|
||||
"IN_A_FEW_HOURS": "Обикновено отговаряме до няколко часа",
|
||||
"IN_A_DAY": "Обикновено отговаряме до един ден",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Започнете разговор",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Започнете нов разговор",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Вижте новите съобщения",
|
||||
"CLOSE_MESSAGES_BUTTON": "Затвори",
|
||||
"COMPANY_FROM": "от",
|
||||
"BOT": "Бот"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Чатете с нас"
|
||||
},
|
||||
"POWERED_BY": "Осъществено от Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Моля, въведете имейла адреса си",
|
||||
"CHAT_PLACEHOLDER": "Напишете вашето съобщение",
|
||||
"TODAY": "Днес",
|
||||
"YESTERDAY": "Вчера",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Пълно име",
|
||||
"PLACEHOLDER": "Моля, въведете пълното си име",
|
||||
"REQUIRED_ERROR": "Пълното име е задължително"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Имейл адрес",
|
||||
"PLACEHOLDER": "Моля, въведете имейл адреса си",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Телефон",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "Няма намерени резултати",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Съобщение",
|
||||
"PLACEHOLDER": "Моля, въведете вашето съобщение",
|
||||
"ERROR": "Съобщението е прекалено кратко"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Преди да започенте разговора, моля, посочете вашето име и имейл адрес",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "Файлът надхвърля {MAXIMUM_FILE_UPLOAD_SIZE} лимит",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Невалидно поле"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Оценете този разговор",
|
||||
"SUBMITTED_TITLE": "Благодарим ви, че оценихте разговора",
|
||||
"PLACEHOLDER": "Разкажете ни повече..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Поискайте копие от разговора",
|
||||
"SEND_EMAIL_SUCCESS": "Копието от разговора бе изпратено успешно",
|
||||
"SEND_EMAIL_ERROR": "Възникна грешка, моля опитайте отново"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Съобщение със снимка"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Аудио съобщение"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Видео съобщение"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Прикачен файл"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Локация"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "сподели линк"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/bn.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/bn.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Submit"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
"CLOSE_MESSAGES_BUTTON": "Close",
|
||||
"COMPANY_FROM": "from",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat with us"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Please enter your email",
|
||||
"CHAT_PLACEHOLDER": "Type your message",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full Name",
|
||||
"PLACEHOLDER": "Please enter your full name",
|
||||
"REQUIRED_ERROR": "Full Name is required"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter your email address",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "No results found",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter your message",
|
||||
"ERROR": "Message too short"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Invalid field"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Request a conversation transcript",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Picture message"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio message"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video message"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "File Attachment"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Location"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "has shared a url"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/ca.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/ca.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Descarrega",
|
||||
"UPLOADING": "S'està carregant..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Envia"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Torna a enviar el missatge",
|
||||
"ERROR_MESSAGE": "No s'ha pogut enviar, torna-ho a provar"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Estem en línia",
|
||||
"OFFLINE": "Estem fora en aquest moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Normalment responem en pocs minuts",
|
||||
"IN_A_FEW_HOURS": "Normalment responem en poques hores",
|
||||
"IN_A_DAY": "Normalment respon en un dia",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Diumenge",
|
||||
"MONDAY": "Dilluns",
|
||||
"TUESDAY": "Dimarts",
|
||||
"WEDNESDAY": "Dimecres",
|
||||
"THURSDAY": "Dijous",
|
||||
"FRIDAY": "Divendres",
|
||||
"SATURDAY": "Dissabte"
|
||||
},
|
||||
"START_CONVERSATION": "Inicia la conversa",
|
||||
"END_CONVERSATION": "Finalitzar la conversa",
|
||||
"CONTINUE_CONVERSATION": "Continua la conversa",
|
||||
"YOU": "Tu",
|
||||
"START_NEW_CONVERSATION": "Inicia una nova conversa",
|
||||
"VIEW_UNREAD_MESSAGES": "Tens missatges no llegits",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Veure missatges nous",
|
||||
"CLOSE_MESSAGES_BUTTON": "Tanca",
|
||||
"COMPANY_FROM": "des de",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Xateja amb nosaltres"
|
||||
},
|
||||
"POWERED_BY": "Desenvolupat per Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Introduïu el correu electrònic",
|
||||
"CHAT_PLACEHOLDER": "Escriu el missatge",
|
||||
"TODAY": "Avui",
|
||||
"YESTERDAY": "Ahir",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Nom complet",
|
||||
"PLACEHOLDER": "Introdueix el vostre nom complet",
|
||||
"REQUIRED_ERROR": "El Nom Complert és obligatori"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Adreça de correu electrònic",
|
||||
"PLACEHOLDER": "Si us plau, introdueix la teva adreça email",
|
||||
"REQUIRED_ERROR": "L'adreça de correu electrònic és necessària",
|
||||
"VALID_ERROR": "Introduïu una adreça de correu electrònic vàlida"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Número de telèfon",
|
||||
"PLACEHOLDER": "Introdueix el vostre número de telèfon",
|
||||
"REQUIRED_ERROR": "El número de telèfon és obligatori",
|
||||
"DIAL_CODE_VALID_ERROR": "Selecciona un codi de país",
|
||||
"VALID_ERROR": "Introdueix un número de telèfon vàlid",
|
||||
"DROPDOWN_EMPTY": "No s'ha trobat agents",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Missatge",
|
||||
"PLACEHOLDER": "Si us plau, introdueix el teu missatge",
|
||||
"ERROR": "Missatge massa curt"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Proporciona el vostre nom i correu electrònic abans d'iniciar la conversa",
|
||||
"IS_REQUIRED": "és necessari",
|
||||
"REQUIRED": "Necessari",
|
||||
"REGEX_ERROR": "Proporciona una entrada vàlida"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "El fitxer supera el límit de {MAXIMUM_FILE_UPLOAD_SIZE} fitxers adjunts",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Camp no vàlid"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Cerca emojis",
|
||||
"NOT_FOUND": "Cap emoji coincideix amb la teva cerca",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Valora la teva conversa",
|
||||
"SUBMITTED_TITLE": "Gràcies per enviar la qualificació",
|
||||
"PLACEHOLDER": "Explica'ns més..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Envia la transcripció de la conversa",
|
||||
"SEND_EMAIL_SUCCESS": "La transcripció del xat s'ha enviat correctament",
|
||||
"SEND_EMAIL_ERROR": "S'ha produït un error; tornau-ho a provar"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Fes clic aquí per unir-te",
|
||||
"LEAVE_THE_ROOM": "Deixa la trucada"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Articles populars",
|
||||
"VIEW_ALL_ARTICLES": "Veure tots els articles",
|
||||
"IFRAME_LOAD_ERROR": "S'ha produït un error en carregar l'article, actualitza la pàgina i torna-ho a provar."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Missatge d'imatge"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Missatge d'àudio"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Missatge de vídeo"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Fitxer adjunt"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Ubicació"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "ha compartit una URL"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Responent a:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/cs.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/cs.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Stáhnout",
|
||||
"UPLOADING": "Nahrávání..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Odeslat"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Odeslat zprávu znovu",
|
||||
"ERROR_MESSAGE": "Odeslání se nezdařilo, zkuste to znovu"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Jsme online",
|
||||
"OFFLINE": "V současné době jsme pryč",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Většinou odpovíme během pár minut",
|
||||
"IN_A_FEW_HOURS": "Většinou odpovíme během pár hodin",
|
||||
"IN_A_DAY": "Obvykle odpoví za den",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "Zítra budeme opět k dispozici",
|
||||
"BACK_IN_SOME_TIME": "Za chvíli budeme opět k dispozici"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Neděle",
|
||||
"MONDAY": "Pondělí",
|
||||
"TUESDAY": "Úterý",
|
||||
"WEDNESDAY": "Středa",
|
||||
"THURSDAY": "Čtvrtek",
|
||||
"FRIDAY": "Pátek",
|
||||
"SATURDAY": "Sobota"
|
||||
},
|
||||
"START_CONVERSATION": "Zahájit konverzaci",
|
||||
"END_CONVERSATION": "Ukončit konverzaci",
|
||||
"CONTINUE_CONVERSATION": "Pokračovat v konverzaci",
|
||||
"YOU": "Vy",
|
||||
"START_NEW_CONVERSATION": "Zahájit novou konverzaci",
|
||||
"VIEW_UNREAD_MESSAGES": "Máte nepřečtené zprávy",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Zobrazit nové zprávy",
|
||||
"CLOSE_MESSAGES_BUTTON": "Zavřít",
|
||||
"COMPANY_FROM": "od",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Napiště nám"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Prosím, zadejte svůj e-mail",
|
||||
"CHAT_PLACEHOLDER": "Zde začněte psát",
|
||||
"TODAY": "Dnes",
|
||||
"YESTERDAY": "Včera",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Celé jméno",
|
||||
"PLACEHOLDER": "Zadejte své celé jméno",
|
||||
"REQUIRED_ERROR": "Je vyžadováno celé jméno"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "E-mailová adresa",
|
||||
"PLACEHOLDER": "Zadejte prosím Váš e-mail",
|
||||
"REQUIRED_ERROR": "E-mailová adresa je vyžadována",
|
||||
"VALID_ERROR": "Zadejte prosím platnou e-mailovou adresu"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Telefonní číslo",
|
||||
"PLACEHOLDER": "Zadejte prosím Vaše telefonní číslo",
|
||||
"REQUIRED_ERROR": "Telefonní číslo je vyžadováno",
|
||||
"DIAL_CODE_VALID_ERROR": "Vyberte prosím kód země",
|
||||
"VALID_ERROR": "Zadejte prosím platné telefonní číslo",
|
||||
"DROPDOWN_EMPTY": "Žádné výsledky",
|
||||
"DROPDOWN_SEARCH": "Vyhledat zemi"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Zpráva",
|
||||
"PLACEHOLDER": "Zadejte prosím Vaši zprávu",
|
||||
"ERROR": "Zpráva je příliš krátká"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Zadejte své jméno a e-mail před zahájením konverzace",
|
||||
"IS_REQUIRED": "je vyžadováno",
|
||||
"REQUIRED": "Povinné",
|
||||
"REGEX_ERROR": "Zadejte prosím platnou hodnotu"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "Soubor překračuje limit {MAXIMUM_FILE_UPLOAD_SIZE} přílohy",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Neplatné pole"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Hledat emoji",
|
||||
"NOT_FOUND": "Žádné emoji neodpovídají vašemu hledání",
|
||||
"ARIA_LABEL": "Výběr emoji"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Ohodnoťte svou konverzaci",
|
||||
"SUBMITTED_TITLE": "Děkujeme Vám za odeslání hodnocení",
|
||||
"PLACEHOLDER": "Řekněte nám více..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Požádat o přepis konverzace",
|
||||
"SEND_EMAIL_SUCCESS": "Přepis chatu byl úspěšně odeslán",
|
||||
"SEND_EMAIL_ERROR": "Došlo k chybě, zkuste to prosím znovu"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Přidejte se kliknutím sem",
|
||||
"LEAVE_THE_ROOM": "Opustit hovor"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Populární články",
|
||||
"VIEW_ALL_ARTICLES": "Zobrazit všechny články",
|
||||
"IFRAME_LOAD_ERROR": "Při načítání článku došlo k chybě, obnovte stránku a zkuste to znovu."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Zpráva obrázku"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Zvuková zpráva"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video zpráva"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Přílohu souboru"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Poloha"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "sdílel URL"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Odpověď na:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/da.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/da.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploader..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Send"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send besked igen",
|
||||
"ERROR_MESSAGE": "Kunne ikke sende, prøv igen"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Vi er online",
|
||||
"OFFLINE": "Vi er ikke tilgængelige i øjeblikket",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Svarer typisk på et par minutter",
|
||||
"IN_A_FEW_HOURS": "Svarer typisk på et par timer",
|
||||
"IN_A_DAY": "Svarer typisk på en dag",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "søndag",
|
||||
"MONDAY": "mandag",
|
||||
"TUESDAY": "tirsdag",
|
||||
"WEDNESDAY": "onsdag",
|
||||
"THURSDAY": "torsdag",
|
||||
"FRIDAY": "fredag",
|
||||
"SATURDAY": "lørdag"
|
||||
},
|
||||
"START_CONVERSATION": "Start Samtale",
|
||||
"END_CONVERSATION": "Afslut Samtale",
|
||||
"CONTINUE_CONVERSATION": "Fortsæt samtale",
|
||||
"YOU": "Dig",
|
||||
"START_NEW_CONVERSATION": "Start en ny samtale",
|
||||
"VIEW_UNREAD_MESSAGES": "Du har ulæste beskeder",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Se nye beskeder",
|
||||
"CLOSE_MESSAGES_BUTTON": "Luk",
|
||||
"COMPANY_FROM": "fra",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat med os"
|
||||
},
|
||||
"POWERED_BY": "Drevet af Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Indtast venligst din e-mail",
|
||||
"CHAT_PLACEHOLDER": "Skriv din besked",
|
||||
"TODAY": "I dag",
|
||||
"YESTERDAY": "I går",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Fulde Navn",
|
||||
"PLACEHOLDER": "Indtast venligst dit fulde navn",
|
||||
"REQUIRED_ERROR": "Det fulde navn er påkrævet"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "E-Mail Adresse",
|
||||
"PLACEHOLDER": "Indtast venligst din e-mail adresse",
|
||||
"REQUIRED_ERROR": "E-mail adresse er påkrævet",
|
||||
"VALID_ERROR": "Angiv venligst en gyldig e-mailadresse"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Telefonnummer",
|
||||
"PLACEHOLDER": "Indtast venligst dit telefonnummer",
|
||||
"REQUIRED_ERROR": "Telefonnummer er påkrævet",
|
||||
"DIAL_CODE_VALID_ERROR": "Vælg venligst en landekode",
|
||||
"VALID_ERROR": "Indtast et gyldigt telefonnummer",
|
||||
"DROPDOWN_EMPTY": "Ingen resultater fundet",
|
||||
"DROPDOWN_SEARCH": "Søg land"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Besked",
|
||||
"PLACEHOLDER": "Indtast venligst din besked",
|
||||
"ERROR": "Beskeden er for kort"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Angiv venligst dit navn og e-mail, før du starter samtalen",
|
||||
"IS_REQUIRED": "er påkrævet",
|
||||
"REQUIRED": "Påkrævet",
|
||||
"REGEX_ERROR": "Angiv venligst et gyldigt input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "Filen overskrider grænsen på {MAXIMUM_FILE_UPLOAD_SIZE} for vedhæftede filer",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Ugyldigt felt"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Søg efter emojis",
|
||||
"NOT_FOUND": "Ingen emoji matcher din søgning",
|
||||
"ARIA_LABEL": "Emoji vælger"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Bedøm din samtale",
|
||||
"SUBMITTED_TITLE": "Tak for din bedømmelse",
|
||||
"PLACEHOLDER": "Fortæl os mere..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Anmod om en samtaleudskrift",
|
||||
"SEND_EMAIL_SUCCESS": "Chatudskriften blev sendt med succes",
|
||||
"SEND_EMAIL_ERROR": "Der opstod en fejl. Prøv venligst igen"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Tryk her for at deltage",
|
||||
"LEAVE_THE_ROOM": "Forlad opkaldet"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Populære Artikler",
|
||||
"VIEW_ALL_ARTICLES": "Se alle artikler",
|
||||
"IFRAME_LOAD_ERROR": "Der opstod en fejl under indlæsning af artiklen. Genindlæs venligst siden og prøv igen."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Billedbesked"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Lydbesked"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video besked"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Fil Vedhæftning"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Lokation"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "har delt en URL"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Besvarer til:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/de.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/de.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Herunterladen",
|
||||
"UPLOADING": "Hochladen..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Abschicken"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Nachricht erneut senden",
|
||||
"ERROR_MESSAGE": "Senden nicht möglich, versuchen Sie es noch einmal"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Wir sind online",
|
||||
"OFFLINE": "Wir sind momentan abwesend",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Wir antworten üblicherweise innerhalb weniger Minuten",
|
||||
"IN_A_FEW_HOURS": "Wir antworten üblicherweise innerhalb weniger Stunden",
|
||||
"IN_A_DAY": "Wir antworten üblicherweise innerhalb eines Tages",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sonntag",
|
||||
"MONDAY": "Montag",
|
||||
"TUESDAY": "Dienstag",
|
||||
"WEDNESDAY": "Mittwoch",
|
||||
"THURSDAY": "Donnerstag",
|
||||
"FRIDAY": "Freitag",
|
||||
"SATURDAY": "Samstag"
|
||||
},
|
||||
"START_CONVERSATION": "Unterhaltung beginnen",
|
||||
"END_CONVERSATION": "Konversation beenden",
|
||||
"CONTINUE_CONVERSATION": "Konversation fortsetzen",
|
||||
"YOU": "Sie",
|
||||
"START_NEW_CONVERSATION": "Neue Unterhaltung starten",
|
||||
"VIEW_UNREAD_MESSAGES": "Sie haben ungelesene Nachrichten",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Neue Nachrichten anzeigen",
|
||||
"CLOSE_MESSAGES_BUTTON": "Schließen",
|
||||
"COMPANY_FROM": "von",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chatten Sie mit uns"
|
||||
},
|
||||
"POWERED_BY": "Unterstützt von Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Bitte geben Sie Ihre E-Mail-Adresse ein",
|
||||
"CHAT_PLACEHOLDER": "Schreiben Sie Ihre Nachricht",
|
||||
"TODAY": "Heute",
|
||||
"YESTERDAY": "Gestern",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Vollständiger Name",
|
||||
"PLACEHOLDER": "Bitte geben Sie Ihren vollständigen Namen ein",
|
||||
"REQUIRED_ERROR": "Vollständiger Name ist erforderlich"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "E-Mail-Adresse",
|
||||
"PLACEHOLDER": "Bitte geben Sie ihre E-Mail-Adresse ein",
|
||||
"REQUIRED_ERROR": "E-Mail-Adresse wird benötigt",
|
||||
"VALID_ERROR": "Bitte geben Sie eine gültige E-Mail-Adresse ein"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Telefonnummer",
|
||||
"PLACEHOLDER": "Bitte geben Sie ihre Telefonnummer ein",
|
||||
"REQUIRED_ERROR": "Telefonnummer ist erforderlich",
|
||||
"DIAL_CODE_VALID_ERROR": "Bitte wählen Sie eine Landesvorwahl",
|
||||
"VALID_ERROR": "Bitte geben Sie eine gültige Telefonnummer ein",
|
||||
"DROPDOWN_EMPTY": "Keine Ergebnisse gefunden",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Nachricht",
|
||||
"PLACEHOLDER": "Bitte geben Sie Ihre Nachricht ein",
|
||||
"ERROR": "Nachricht ist zu kurz"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Bitte geben Sie Ihren Namen und Ihre E-Mail-Adresse an, bevor Sie die Konversation beginnen",
|
||||
"IS_REQUIRED": "erforderlich",
|
||||
"REQUIRED": "Benötigt",
|
||||
"REGEX_ERROR": "Bitte geben Sie eine gültige Eingabe ein"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "Die Datei überschreitet das Anhangslimit von {MAXIMUM_FILE_UPLOAD_SIZE}",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Ungültiges Feld"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Emojis durchsuchen",
|
||||
"NOT_FOUND": "Kein Emoji entspricht Ihrer Suche",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Bewerten Sie Ihre Konversation",
|
||||
"SUBMITTED_TITLE": "Danke, dass Sie die Bewertung eingereicht haben",
|
||||
"PLACEHOLDER": "Erzählen Sie uns mehr..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Chat-Protokoll anfordern",
|
||||
"SEND_EMAIL_SUCCESS": "Das Chat-Protokoll wurde erfolgreich gesendet",
|
||||
"SEND_EMAIL_ERROR": "Es ist ein Fehler aufgetreten, bitte versuchen Sie es erneut"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Klicken Sie hier, um beizutreten",
|
||||
"LEAVE_THE_ROOM": "Anruf verlassen"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Beliebte Artikel",
|
||||
"VIEW_ALL_ARTICLES": "Alle Artikel anzeigen",
|
||||
"IFRAME_LOAD_ERROR": "Beim Laden des Artikels ist ein Fehler aufgetreten. Bitte aktualisieren Sie die Seite und versuchen Sie es erneut."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Bildnachricht"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio-Nachricht"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Videonachricht"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Dateianhang"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Ort"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "hat eine URL geteilt"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Antwort auf:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/el.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/el.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Κατέβασμα",
|
||||
"UPLOADING": "Ανέβασμα..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Καταχώρηση"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Επαναποστολή μηνύματος",
|
||||
"ERROR_MESSAGE": "Αδυναμία αποστολής! Προσπαθήστε ξανά"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Είμαστε online",
|
||||
"OFFLINE": "Προς το παρόν, είμαστε εκτός",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Τυπικά έχετε απάντηση σε μερικά λεπτά",
|
||||
"IN_A_FEW_HOURS": "Τυπικά έχετε απάντηση σε μερικές σε μερικές ώρες",
|
||||
"IN_A_DAY": "Τυπικά έχετε απάντηση σε μία ημέρα",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Έναρξη Συνομιλίας",
|
||||
"END_CONVERSATION": "Τέλος Συνομιλίας",
|
||||
"CONTINUE_CONVERSATION": "Συνέχιση συνομιλίας",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Έναρξη νέας συνομιλίας",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Δείτε τα νέα μηνύματα",
|
||||
"CLOSE_MESSAGES_BUTTON": "Κλείσιμο",
|
||||
"COMPANY_FROM": "από",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Συνομιλήστε μαζί μας"
|
||||
},
|
||||
"POWERED_BY": "με την δύναμη του Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Παρακαλώ εισάγετε το email σας",
|
||||
"CHAT_PLACEHOLDER": "Πληκτρολογήστε το μήνυμά σας",
|
||||
"TODAY": "Σήμερα",
|
||||
"YESTERDAY": "Χτες",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Πλήρες Όνομα",
|
||||
"PLACEHOLDER": "Παρακαλώ συμπληρώστε το πλήρες όνομα σας",
|
||||
"REQUIRED_ERROR": "Απαιτείται πλήρες όνομα"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Διεύθυνση Email",
|
||||
"PLACEHOLDER": "Παρακαλούμε εισάγετε τη διεύθυνση email σας",
|
||||
"REQUIRED_ERROR": "Απαιτείται διεύθυνση email",
|
||||
"VALID_ERROR": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση email"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Αριθμός Τηλεφώνου",
|
||||
"PLACEHOLDER": "Παρακαλώ εισάγετε τον αριθμό του τηλεφώνου σας",
|
||||
"REQUIRED_ERROR": "Απαιτείται ο αριθμός τηλεφώνου",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "Δεν βρέθηκαν αποτελέσματα",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Μήνυμα",
|
||||
"PLACEHOLDER": "Παρακαλώ εισάγετε το μήνυμά σας",
|
||||
"ERROR": "Πολύ σύντομο μήνυμα"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Παρακαλώ δώστε το όνομα και το email σας πριν ξεκινήσετε την συνομιλία",
|
||||
"IS_REQUIRED": "είναι απαραίτητο",
|
||||
"REQUIRED": "Υποχρεωτικό",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "Το αρχείο υπερβαίνει το όριο συνημμένου {MAXIMUM_FILE_UPLOAD_SIZE}",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Μη έγκυρο πεδίο"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Αναζήτηση emojis",
|
||||
"NOT_FOUND": "Κανένα emoji δεν ταιριάζει με την αναζήτησή σας",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Αξιολογήστε τη συνομιλία σας",
|
||||
"SUBMITTED_TITLE": "Ευχαριστούμε για την υποβολή της αξιολόγησης",
|
||||
"PLACEHOLDER": "Πείτε μας περισσότερα..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Αίτηση μεταγραφής συνομιλίας",
|
||||
"SEND_EMAIL_SUCCESS": "Η μεταγραφή της συνομιλίας έχει αποσταλεί επιτυχώς",
|
||||
"SEND_EMAIL_ERROR": "Υπήρξε ένα σφάλμα, παρακαλώ προσπαθήστε ξανά"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Κάντε κλικ εδώ για να συμμετάσχετε",
|
||||
"LEAVE_THE_ROOM": "Έξοδος από την κλήση"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Μήνυμα εικόνας"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Μήνυμα ήχου"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Μήνυμα βίντεο"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Επισυναπτόμενο αρχείο"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Θέση"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "έχει μοιράσει ένα σύνδεσμο"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/en.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/en.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Submit"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
"CLOSE_MESSAGES_BUTTON": "Close",
|
||||
"COMPANY_FROM": "from",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat with us"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Please enter your email",
|
||||
"CHAT_PLACEHOLDER": "Type your message",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full Name",
|
||||
"PLACEHOLDER": "Please enter your full name",
|
||||
"REQUIRED_ERROR": "Full Name is required"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter your email address",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "No results found",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter your message",
|
||||
"ERROR": "Message too short"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Invalid field"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Request a conversation transcript",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Picture message"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio message"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video message"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "File Attachment"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Location"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "has shared a url"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/es.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/es.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Descargar",
|
||||
"UPLOADING": "Subiendo..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Enviar"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Enviar mensaje de nuevo",
|
||||
"ERROR_MESSAGE": "¡No se pudo enviar! intente nuevamente"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "Estamos en línea",
|
||||
"OFFLINE": "Estamos ausentes en este momento",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Normalmente responde en unos minutos",
|
||||
"IN_A_FEW_HOURS": "Normalmente responde en unas pocas horas",
|
||||
"IN_A_DAY": "Normalmente responde en un día",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Domingo",
|
||||
"MONDAY": "Lunes",
|
||||
"TUESDAY": "Martes",
|
||||
"WEDNESDAY": "Miércoles",
|
||||
"THURSDAY": "Jueves",
|
||||
"FRIDAY": "Viernes",
|
||||
"SATURDAY": "Sábado"
|
||||
},
|
||||
"START_CONVERSATION": "Iniciar conversación",
|
||||
"END_CONVERSATION": "Finalizar conversación",
|
||||
"CONTINUE_CONVERSATION": "Continuar conversación",
|
||||
"YOU": "Tú",
|
||||
"START_NEW_CONVERSATION": "Iniciar una nueva conversación",
|
||||
"VIEW_UNREAD_MESSAGES": "Tienes mensajes no leídos",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "Ver nuevos mensajes",
|
||||
"CLOSE_MESSAGES_BUTTON": "Cerrar",
|
||||
"COMPANY_FROM": "De",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chatea con nosotros"
|
||||
},
|
||||
"POWERED_BY": "Desarrollado por Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Por favor ingrese su email",
|
||||
"CHAT_PLACEHOLDER": "Escribe tu mensaje",
|
||||
"TODAY": "Hoy",
|
||||
"YESTERDAY": "Ayer",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Nombre completo",
|
||||
"PLACEHOLDER": "Introduzca su nombre completo por favor",
|
||||
"REQUIRED_ERROR": "Nombre completo es obligatorio"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Dirección de correo",
|
||||
"PLACEHOLDER": "Introduzca su dirección de correo electrónico",
|
||||
"REQUIRED_ERROR": "La dirección de correo es obligatoria",
|
||||
"VALID_ERROR": "Por favor, introduzca una dirección de correo válida"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Número telefónico",
|
||||
"PLACEHOLDER": "Por favor ingrese su número de teléfono",
|
||||
"REQUIRED_ERROR": "El número de teléfono es obligatorio",
|
||||
"DIAL_CODE_VALID_ERROR": "Por favor seleccione un código de país",
|
||||
"VALID_ERROR": "Por favor ingrese un número de teléfono válido",
|
||||
"DROPDOWN_EMPTY": "No se encontraron resultados",
|
||||
"DROPDOWN_SEARCH": "Buscar país"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Mensaje",
|
||||
"PLACEHOLDER": "Introduzca su mensaje",
|
||||
"ERROR": "Mensaje demasiado corto"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Por favor, introduce tu nombre y correo electrónico antes de iniciar la conversación",
|
||||
"IS_REQUIRED": "es obligatorio",
|
||||
"REQUIRED": "Requerido",
|
||||
"REGEX_ERROR": "Por favor proporcione una entrada válida"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "El archivo excede el límite de los archivos adjuntos {MAXIMUM_FILE_UPLOAD_SIZE}",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Campo no válido"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Buscar emojis",
|
||||
"NOT_FOUND": "Ningún emoji coincide con tu búsqueda",
|
||||
"ARIA_LABEL": "Selector de emoji"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Califica tu conversación",
|
||||
"SUBMITTED_TITLE": "Gracias por enviar la valoración",
|
||||
"PLACEHOLDER": "Cuéntanos más..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Enviar transcripción de la conversación",
|
||||
"SEND_EMAIL_SUCCESS": "La transcripción ha sido enviada",
|
||||
"SEND_EMAIL_ERROR": "Hubo un error, por favor inténtelo de nuevo"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Haga clic aquí para unirse",
|
||||
"LEAVE_THE_ROOM": "Salir de la llamada"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Artículos populares",
|
||||
"VIEW_ALL_ARTICLES": "Ver todos los artículos",
|
||||
"IFRAME_LOAD_ERROR": "Se ha producido un error al cargar el artículo, por favor actualiza la página e inténtalo de nuevo."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Mensaje de imagen"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Mensaje de audio"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Mensaje de vídeo"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "Archivo adjunto"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Ubicación"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "ha compartido una url"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Respondiendo a:"
|
||||
}
|
||||
}
|
||||
153
research/chatwoot/app/javascript/widget/i18n/locale/et.json
Normal file
153
research/chatwoot/app/javascript/widget/i18n/locale/et.json
Normal file
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Submit"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
},
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
"CLOSE_MESSAGES_BUTTON": "Close",
|
||||
"COMPANY_FROM": "from",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat with us"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Please enter your email",
|
||||
"CHAT_PLACEHOLDER": "Type your message",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full Name",
|
||||
"PLACEHOLDER": "Please enter your full name",
|
||||
"REQUIRED_ERROR": "Full Name is required"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter your email address",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "No results found",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter your message",
|
||||
"ERROR": "Message too short"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Invalid field"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Request a conversation transcript",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Picture message"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio message"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video message"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "File Attachment"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Location"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "has shared a url"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user