Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user