Feat: business hours settings page (#1812)

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Nithin David Thomas 2021-03-13 11:42:51 +05:30 committed by GitHub
parent dbf515ab5a
commit 1d2e1a2823
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 577 additions and 11 deletions

View file

@ -274,6 +274,12 @@
"TITLE": "Set your availability", "TITLE": "Set your availability",
"SUBTITLE": "Set your availability on your livechat widget", "SUBTITLE": "Set your availability on your livechat widget",
"WEEKLY_TITLE": "Set your weekly hours", "WEEKLY_TITLE": "Set your weekly hours",
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": { "DAY": {
"ENABLE": "Enable availability for this day", "ENABLE": "Enable availability for this day",
"UNAVAILABLE": "Unavailable", "UNAVAILABLE": "Unavailable",

View file

@ -254,6 +254,9 @@
<div v-if="selectedTabKey === 'preChatForm'"> <div v-if="selectedTabKey === 'preChatForm'">
<pre-chat-form-settings :inbox="inbox" /> <pre-chat-form-settings :inbox="inbox" />
</div> </div>
<div v-if="selectedTabKey === 'businesshours'">
<weekly-availability :inbox="inbox" />
</div>
</div> </div>
</template> </template>
@ -266,12 +269,14 @@ import SettingsSection from '../../../../components/SettingsSection';
import inboxMixin from 'shared/mixins/inboxMixin'; import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize'; import FacebookReauthorize from './facebook/Reauthorize';
import PreChatFormSettings from './PreChatForm/Settings'; import PreChatFormSettings from './PreChatForm/Settings';
import WeeklyAvailability from './components/WeeklyAvailability';
export default { export default {
components: { components: {
SettingsSection, SettingsSection,
FacebookReauthorize, FacebookReauthorize,
PreChatFormSettings, PreChatFormSettings,
WeeklyAvailability,
}, },
mixins: [alertMixin, configMixin, inboxMixin], mixins: [alertMixin, configMixin, inboxMixin],
data() { data() {
@ -329,6 +334,10 @@ export default {
key: 'preChatForm', key: 'preChatForm',
name: this.$t('INBOX_MGMT.TABS.PRE_CHAT_FORM'), name: this.$t('INBOX_MGMT.TABS.PRE_CHAT_FORM'),
}, },
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{ {
key: 'configuration', key: 'configuration',
name: this.$t('INBOX_MGMT.TABS.CONFIGURATION'), name: this.$t('INBOX_MGMT.TABS.CONFIGURATION'),

View file

@ -92,11 +92,13 @@ export default {
...this.timeSlot, ...this.timeSlot,
from: timeSlots[0], from: timeSlots[0],
to: timeSlots[16], to: timeSlots[16],
valid: true,
} }
: { : {
...this.timeSlot, ...this.timeSlot,
from: '', from: '',
to: '', to: '',
valid: false,
}; };
this.$emit('update', newSlot); this.$emit('update', newSlot);
}, },
@ -106,9 +108,12 @@ export default {
return this.timeSlot.from; return this.timeSlot.from;
}, },
set(value) { set(value) {
const fromDate = parse(value, 'hh:mm a', new Date());
const valid = differenceInMinutes(this.toDate, fromDate) / 60 > 0;
this.$emit('update', { this.$emit('update', {
...this.timeSlot, ...this.timeSlot,
from: value, from: value,
valid,
}); });
}, },
}, },
@ -117,10 +122,21 @@ export default {
return this.timeSlot.to; return this.timeSlot.to;
}, },
set(value) { set(value) {
this.$emit('update', { const toDate = parse(value, 'hh:mm a', new Date());
...this.timeSlot, if (value === '12:00 AM') {
to: value, this.$emit('update', {
}); ...this.timeSlot,
to: value,
valid: true,
});
} else {
const valid = differenceInMinutes(toDate, this.fromDate) / 60 > 0;
this.$emit('update', {
...this.timeSlot,
to: value,
valid,
});
}
}, },
}, },
fromDate() { fromDate() {
@ -130,10 +146,14 @@ export default {
return parse(this.toTime, 'hh:mm a', new Date()); return parse(this.toTime, 'hh:mm a', new Date());
}, },
totalHours() { totalHours() {
return differenceInMinutes(this.toDate, this.fromDate) / 60; const totalHours = differenceInMinutes(this.toDate, this.fromDate) / 60;
if (this.toTime === '12:00 AM') {
return 24 + totalHours;
}
return totalHours;
}, },
hasError() { hasError() {
return this.totalHours < 0; return !this.timeSlot.valid;
}, },
}, },
}; };
@ -157,8 +177,8 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: var(--space-normal); padding: var(--space-small) 0;
height: var(--space-larger); min-height: var(--space-larger);
box-sizing: content-box; box-sizing: content-box;
border-bottom: 1px solid var(--color-border-light); border-bottom: 1px solid var(--color-border-light);
} }
@ -213,7 +233,7 @@ export default {
} }
.date-error { .date-error {
padding: var(--space-small) 0; padding-top: var(--space-smaller);
} }
.error { .error {

View file

@ -0,0 +1,193 @@
<template>
<div class="settings--content">
<settings-section
:title="$t('INBOX_MGMT.BUSINESS_HOURS.TITLE')"
:sub-title="$t('INBOX_MGMT.BUSINESS_HOURS.SUBTITLE')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-business-hours" class="toggle-input-wrap">
<input
v-model="isBusinessHoursEnabled"
type="checkbox"
name="toggle-business-hours"
/>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_AVAILABILITY') }}
</label>
<p>{{ $t('INBOX_MGMT.BUSINESS_HOURS.TOGGLE_HELP') }}</p>
<div v-if="isBusinessHoursEnabled" class="business-hours-wrap">
<label class="unavailable-input-wrap">
{{ $t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_LABEL') }}
<textarea v-model="unavailableMessage" type="text" />
</label>
<div class="timezone-input-wrap">
<label>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.TIMEZONE_LABEL') }}
</label>
<multiselect
v-model="timeZone"
:options="timeZones"
deselect-label=""
select-label=""
selected-label=""
track-by="value"
label="label"
:close-on-select="true"
:placeholder="$t('INBOX_MGMT.BUSINESS_HOURS.DAY.CHOOSE')"
:allow-empty="false"
/>
</div>
<label>
{{ $t('INBOX_MGMT.BUSINESS_HOURS.WEEKLY_TITLE') }}
</label>
<business-day
v-for="timeSlot in timeSlots"
:key="timeSlot.day"
:day-name="dayNames[timeSlot.day]"
:time-slot="timeSlot"
@update="data => onSlotUpdate(timeSlot.day, data)"
/>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.BUSINESS_HOURS.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
:disabled="hasError"
/>
</form>
</settings-section>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import BusinessDay from './BusinessDay';
import {
timeSlotParse,
timeSlotTransform,
defaultTimeSlot,
timeZoneOptions,
} from '../helpers/businessHour';
const DEFAULT_TIMEZONE = {
label: 'America/Los_Angeles',
key: 'America/Los_Angeles',
};
export default {
components: {
SettingsSection,
BusinessDay,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return {
isBusinessHoursEnabled: false,
unavailableMessage: this.$t(
'INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT'
),
timeZone: DEFAULT_TIMEZONE,
dayNames: {
0: 'Sunday',
1: 'Monday',
2: 'Tuesday',
3: 'Wednesday',
4: 'Thursday',
5: 'Friday',
6: 'Saturday',
},
timeSlots: [...defaultTimeSlot],
};
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
hasError() {
if (!this.isBusinessHoursEnabled) return false;
return this.timeSlots.filter(slot => slot.from && !slot.valid).length > 0;
},
timeZones() {
return [...timeZoneOptions()];
},
},
watch: {
inbox() {
this.setDefaults();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
const {
working_hours_enabled: isEnabled = false,
out_of_office_message: unavailableMessage,
working_hours: timeSlots = [],
timezone: timeZone,
} = this.inbox;
const slots = timeSlotParse(timeSlots).length
? timeSlotParse(timeSlots)
: defaultTimeSlot;
this.isBusinessHoursEnabled = isEnabled;
this.unavailableMessage =
unavailableMessage ||
this.$t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT');
this.timeSlots = slots;
this.timeZone =
this.timeZones.find(item => timeZone === item.value) ||
DEFAULT_TIMEZONE;
},
onSlotUpdate(slotIndex, slotData) {
this.timeSlots = this.timeSlots.map(item =>
item.day === slotIndex ? slotData : item
);
},
async updateInbox() {
try {
const payload = {
id: this.inbox.id,
formData: false,
working_hours_enabled: this.isBusinessHoursEnabled,
out_of_office_message: this.unavailableMessage,
working_hours: timeSlotTransform(this.timeSlots),
timezone: this.timeZone.value,
channel: {},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
this.showAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.timezone-input-wrap {
max-width: 60rem;
&::v-deep .multiselect {
margin-top: var(--space-small);
}
}
.unavailable-input-wrap {
max-width: 60rem;
textarea {
min-height: var(--space-jumbo);
margin-top: var(--space-small);
}
}
.business-hours-wrap {
margin-bottom: var(--space-medium);
}
</style>

View file

@ -1,3 +1,53 @@
import parse from 'date-fns/parse';
import getHours from 'date-fns/getHours';
import getMinutes from 'date-fns/getMinutes';
import timeZoneData from './timezones.json';
export const defaultTimeSlot = [
{
day: 0,
to: '',
from: '',
valid: false,
},
{
day: 1,
to: '',
from: '',
valid: false,
},
{
day: 2,
to: '',
from: '',
valid: false,
},
{
day: 3,
to: '',
from: '',
valid: false,
},
{
day: 4,
to: '',
from: '',
valid: false,
},
{
day: 5,
to: '',
from: '',
valid: false,
},
{
day: 6,
to: '',
from: '',
valid: false,
},
];
export const generateTimeSlots = (step = 15) => { export const generateTimeSlots = (step = 15) => {
/* /*
Generates a list of time strings from 12:00 AM to next 24 hours. Each new string Generates a list of time strings from 12:00 AM to next 24 hours. Each new string
@ -18,3 +68,70 @@ export const generateTimeSlots = (step = 15) => {
} }
return slots; return slots;
}; };
export const getTime = (hour, minute) => {
const merdian = hour > 11 ? 'PM' : 'AM';
const modHour = hour > 12 ? hour % 12 : hour || 12;
const parsedHour = modHour < 10 ? `0${modHour}` : modHour;
const parsedMinute = minute < 10 ? `0${minute}` : minute;
return `${parsedHour}:${parsedMinute} ${merdian}`;
};
export const timeSlotParse = timeSlots => {
return timeSlots.map(slot => {
const {
day_of_week: day,
open_hour: openHour,
open_minutes: openMinutes,
close_hour: closeHour,
close_minutes: closeMinutes,
closed_all_day: closedAllDay,
} = slot;
const from = closedAllDay ? '' : getTime(openHour, openMinutes);
const to = closedAllDay ? '' : getTime(closeHour, closeMinutes);
return {
day,
to,
from,
valid: !closedAllDay,
};
});
};
export const timeSlotTransform = timeSlots => {
return timeSlots.map(slot => {
const closed = !(slot.to && slot.from);
let fromDate = '';
let toDate = '';
let openHour = '';
let openMinutes = '';
let closeHour = '';
let closeMinutes = '';
if (!closed) {
fromDate = parse(slot.from, 'hh:mm a', new Date());
toDate = parse(slot.to, 'hh:mm a', new Date());
openHour = getHours(fromDate);
openMinutes = getMinutes(fromDate);
closeHour = getHours(toDate);
closeMinutes = getMinutes(toDate);
}
return {
day_of_week: slot.day,
closed_all_day: closed,
open_hour: openHour,
open_minutes: openMinutes,
close_hour: closeHour,
close_minutes: closeMinutes,
};
});
};
export const timeZoneOptions = () => {
return Object.keys(timeZoneData).map(key => ({
label: key,
value: timeZoneData[key],
}));
};

View file

@ -1,4 +1,10 @@
import { generateTimeSlots } from '../businessHour'; import {
generateTimeSlots,
getTime,
timeSlotParse,
timeSlotTransform,
timeZoneOptions,
} from '../businessHour';
describe('#generateTimeSlots', () => { describe('#generateTimeSlots', () => {
it('returns correct number of time slots', () => { it('returns correct number of time slots', () => {
@ -15,3 +21,65 @@ describe('#generateTimeSlots', () => {
]); ]);
}); });
}); });
describe('#getTime', () => {
it('returns parses 24 hour time correctly', () => {
expect(getTime(15, 30)).toStrictEqual('03:30 PM');
});
it('returns parses 12 hour time correctly', () => {
expect(getTime(12, 30)).toStrictEqual('12:30 PM');
});
});
describe('#timeSlotParse', () => {
it('returns parses correctly', () => {
const slot = {
day_of_week: 1,
open_hour: 1,
open_minutes: 30,
close_hour: 4,
close_minutes: 30,
closed_all_day: false,
};
expect(timeSlotParse([slot])).toStrictEqual([
{
day: 1,
from: '01:30 AM',
to: '04:30 AM',
valid: true,
},
]);
});
});
describe('#timeSlotTransform', () => {
it('returns transforms correctly', () => {
const slot = {
day: 1,
from: '01:30 AM',
to: '04:30 AM',
valid: true,
};
expect(timeSlotTransform([slot])).toStrictEqual([
{
day_of_week: 1,
open_hour: 1,
open_minutes: 30,
close_hour: 4,
close_minutes: 30,
closed_all_day: false,
},
]);
});
});
describe('#timeZoneOptions', () => {
it('returns transforms correctly', () => {
expect(timeZoneOptions()[0]).toStrictEqual({
value: 'Etc/GMT+12',
label: 'International Date Line West',
});
});
});

View file

@ -0,0 +1,153 @@
{
"International Date Line West": "Etc/GMT+12",
"Midway Island": "Pacific/Midway",
"American Samoa": "Pacific/Pago_Pago",
"Hawaii": "Pacific/Honolulu",
"Alaska": "America/Juneau",
"Pacific Time (US & Canada)": "America/Los_Angeles",
"Tijuana": "America/Tijuana",
"Mountain Time (US & Canada)": "America/Denver",
"Arizona": "America/Phoenix",
"Chihuahua": "America/Chihuahua",
"Mazatlan": "America/Mazatlan",
"Central Time (US & Canada)": "America/Chicago",
"Saskatchewan": "America/Regina",
"Guadalajara": "America/Mexico_City",
"Mexico City": "America/Mexico_City",
"Monterrey": "America/Monterrey",
"Central America": "America/Guatemala",
"Eastern Time (US & Canada)": "America/New_York",
"Indiana (East)": "America/Indiana/Indianapolis",
"Bogota": "America/Bogota",
"Lima": "America/Lima",
"Quito": "America/Lima",
"Atlantic Time (Canada)": "America/Halifax",
"Caracas": "America/Caracas",
"La Paz": "America/La_Paz",
"Santiago": "America/Santiago",
"Newfoundland": "America/St_Johns",
"Brasilia": "America/Sao_Paulo",
"Buenos Aires": "America/Argentina/Buenos_Aires",
"Montevideo": "America/Montevideo",
"Georgetown": "America/Guyana",
"Puerto Rico": "America/Puerto_Rico",
"Greenland": "America/Godthab",
"Mid-Atlantic": "Atlantic/South_Georgia",
"Azores": "Atlantic/Azores",
"Cape Verde Is.": "Atlantic/Cape_Verde",
"Dublin": "Europe/Dublin",
"Edinburgh": "Europe/London",
"Lisbon": "Europe/Lisbon",
"London": "Europe/London",
"Casablanca": "Africa/Casablanca",
"Monrovia": "Africa/Monrovia",
"UTC": "Etc/UTC",
"Belgrade": "Europe/Belgrade",
"Bratislava": "Europe/Bratislava",
"Budapest": "Europe/Budapest",
"Ljubljana": "Europe/Ljubljana",
"Prague": "Europe/Prague",
"Sarajevo": "Europe/Sarajevo",
"Skopje": "Europe/Skopje",
"Warsaw": "Europe/Warsaw",
"Zagreb": "Europe/Zagreb",
"Brussels": "Europe/Brussels",
"Copenhagen": "Europe/Copenhagen",
"Madrid": "Europe/Madrid",
"Paris": "Europe/Paris",
"Amsterdam": "Europe/Amsterdam",
"Berlin": "Europe/Berlin",
"Bern": "Europe/Zurich",
"Zurich": "Europe/Zurich",
"Rome": "Europe/Rome",
"Stockholm": "Europe/Stockholm",
"Vienna": "Europe/Vienna",
"West Central Africa": "Africa/Algiers",
"Bucharest": "Europe/Bucharest",
"Cairo": "Africa/Cairo",
"Helsinki": "Europe/Helsinki",
"Kyiv": "Europe/Kiev",
"Riga": "Europe/Riga",
"Sofia": "Europe/Sofia",
"Tallinn": "Europe/Tallinn",
"Vilnius": "Europe/Vilnius",
"Athens": "Europe/Athens",
"Istanbul": "Europe/Istanbul",
"Minsk": "Europe/Minsk",
"Jerusalem": "Asia/Jerusalem",
"Harare": "Africa/Harare",
"Pretoria": "Africa/Johannesburg",
"Kaliningrad": "Europe/Kaliningrad",
"Moscow": "Europe/Moscow",
"St. Petersburg": "Europe/Moscow",
"Volgograd": "Europe/Volgograd",
"Samara": "Europe/Samara",
"Kuwait": "Asia/Kuwait",
"Riyadh": "Asia/Riyadh",
"Nairobi": "Africa/Nairobi",
"Baghdad": "Asia/Baghdad",
"Tehran": "Asia/Tehran",
"Abu Dhabi": "Asia/Muscat",
"Muscat": "Asia/Muscat",
"Baku": "Asia/Baku",
"Tbilisi": "Asia/Tbilisi",
"Yerevan": "Asia/Yerevan",
"Kabul": "Asia/Kabul",
"Ekaterinburg": "Asia/Yekaterinburg",
"Islamabad": "Asia/Karachi",
"Karachi": "Asia/Karachi",
"Tashkent": "Asia/Tashkent",
"Chennai": "Asia/Kolkata",
"Kolkata": "Asia/Kolkata",
"Mumbai": "Asia/Kolkata",
"New Delhi": "Asia/Kolkata",
"Kathmandu": "Asia/Kathmandu",
"Astana": "Asia/Dhaka",
"Dhaka": "Asia/Dhaka",
"Sri Jayawardenepura": "Asia/Colombo",
"Almaty": "Asia/Almaty",
"Novosibirsk": "Asia/Novosibirsk",
"Rangoon": "Asia/Rangoon",
"Bangkok": "Asia/Bangkok",
"Hanoi": "Asia/Bangkok",
"Jakarta": "Asia/Jakarta",
"Krasnoyarsk": "Asia/Krasnoyarsk",
"Beijing": "Asia/Shanghai",
"Chongqing": "Asia/Chongqing",
"Hong Kong": "Asia/Hong_Kong",
"Urumqi": "Asia/Urumqi",
"Kuala Lumpur": "Asia/Kuala_Lumpur",
"Singapore": "Asia/Singapore",
"Taipei": "Asia/Taipei",
"Perth": "Australia/Perth",
"Irkutsk": "Asia/Irkutsk",
"Ulaanbaatar": "Asia/Ulaanbaatar",
"Seoul": "Asia/Seoul",
"Osaka": "Asia/Tokyo",
"Sapporo": "Asia/Tokyo",
"Tokyo": "Asia/Tokyo",
"Yakutsk": "Asia/Yakutsk",
"Darwin": "Australia/Darwin",
"Adelaide": "Australia/Adelaide",
"Canberra": "Australia/Melbourne",
"Melbourne": "Australia/Melbourne",
"Sydney": "Australia/Sydney",
"Brisbane": "Australia/Brisbane",
"Hobart": "Australia/Hobart",
"Vladivostok": "Asia/Vladivostok",
"Guam": "Pacific/Guam",
"Port Moresby": "Pacific/Port_Moresby",
"Magadan": "Asia/Magadan",
"Srednekolymsk": "Asia/Srednekolymsk",
"Solomon Is.": "Pacific/Guadalcanal",
"New Caledonia": "Pacific/Noumea",
"Fiji": "Pacific/Fiji",
"Kamchatka": "Asia/Kamchatka",
"Marshall Is.": "Pacific/Majuro",
"Auckland": "Pacific/Auckland",
"Wellington": "Pacific/Auckland",
"Nuku'alofa": "Pacific/Tongatapu",
"Tokelau Is.": "Pacific/Fakaofo",
"Chatham Is.": "Pacific/Chatham",
"Samoa": "Pacific/Apia"
}

View file

@ -19,7 +19,7 @@ module OutOfOffisable
end end
def weekly_schedule def weekly_schedule
working_hours.select(*OFFISABLE_ATTRS).as_json(except: :id) working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id)
end end
# accepts an array of hashes similiar to the format of weekly_schedule # accepts an array of hashes similiar to the format of weekly_schedule