Merge pull request #1090 from Safelite/feature/CSR-1137

CSR-1137 | Time slot selection modal for in shop
This commit is contained in:
scottkiener 2023-05-19 09:33:14 -04:00 committed by GitHub
commit ee4e25c62b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 516 additions and 28 deletions

View file

@ -26,7 +26,7 @@ module.exports = {
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],
coverageThreshold: {
global: {
statements: 82,
statements: 80,
// Got the go ahead from Mark to temporarily lower this. Taking out initialize component made the year,make,model and style coverage drop a bit. Once unit tests for license plate lookup, vin lookup and address lookup are in the coverage should go back up to 90
},
},

View file

@ -111,6 +111,11 @@ const endpoints = {
mockUrl: "https://mockey.qa.sagaws.net/service/shop-time-slots", // TODO: REMOVE MOCKURL
method: "POST",
},
GetMobileEarlyBirdFee: {
url: "/parts/api/v1/parts/mobile-early-bird-fee",
mockUrl: "https://mockey.qa.sagaws.net/service/mobile-early-bird-fee", // TODO: REMOVE MOCKURL
method: "GET",
},
SaveSession: {
url: "/order/api/v1/order/save-session",
method: "POST",

View file

@ -34,6 +34,7 @@ const storeActions = {
GET_SERVICEABILITY_DETAILS: "getServiceabilityDetails",
GET_SHOP_TIME_SLOTS: "getShopTimeSlots",
GET_PROVIDERS: "getProviders",
GET_MOBILE_EARLY_BIRD_FEE: "getMobileEarlyBirdFee",
SAVE_SESSION: "saveSession",
LOAD_SESSION: "loadSession",
UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE: "updateStoreWithSaveSessionResponse",

View file

@ -120,7 +120,6 @@ export default {
isRequired: Boolean,
isOverflowScrollable: Boolean,
isWide: Boolean,
isCashOrInsurance: Boolean,
modelValue: [Array, Number, String],
value: [Number, String],
validationRules: String,
@ -182,6 +181,7 @@ export default {
getComponentLoopWrapperClasses() {
let classes;
switch (this.buttonTypeString) {
case "timeSlotModalListButton":
case "listButton":
classes = "w-100";
break;

View file

@ -45,6 +45,7 @@
type="radio"
name="day-of-month"
v-model="selectedDate"
@click="fireDateClickedEvent"
:value="date.inputValue"
:id="`${month.monthLabel}-${date.dateNum.toString()}`" />
<label :for="`${month.monthLabel}-${date.dateNum.toString()}`">
@ -145,6 +146,9 @@ export default {
},
},
methods: {
fireDateClickedEvent() {
this.$emit("date-clicked");
},
getWeekStartDate(date) {
// Get the day of the week for date
let dayOfWeek = date.getDay();
@ -306,8 +310,7 @@ export default {
const monthsAfterToLoadOffset = 12; // TO BE MADE "CONSTANTS"
const monthsBeforeToLoadOffset = 36; // TO BE MADE "CONSTANTS"
config.initialShopTimeSlotsResponse.forEach((selectableDate) => {
config.initialShopTimeSlotsResponse.days.forEach((selectableDate) => {
this.selectableDatesData.push(selectableDate);
});
@ -522,7 +525,7 @@ export default {
monthStart,
monthEnd
);
moreSelectableDates.forEach((selectableDate) => {
moreSelectableDates.days.forEach((selectableDate) => {
const index = this.selectableDatesData.findIndex(
(obj) => obj.dateString === selectableDate.dateString
);

View file

@ -21,4 +21,6 @@ const MONTHS_OF_YEAR = [
"December",
];
export { TIMINGFUNC_MAP, BUFFER_OFFSET, MONTHS_OF_YEAR };
const DAYS_OF_WEEK = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
export { TIMINGFUNC_MAP, BUFFER_OFFSET, MONTHS_OF_YEAR, DAYS_OF_WEEK };

View file

@ -33,6 +33,7 @@ export default {
<style lang="scss" scoped>
.text-block {
display: flex;
&.left {
justify-content: flex-start;
}

View file

@ -33,7 +33,23 @@
selectableDatesSetting="custom"
ref="datePicker"
v-model="selectedDate"
:customSelectableDatesCallback="getAvailableDatesMethod" />
:customSelectableDatesCallback="getAvailableDatesMethod"
@date-clicked="openInshopTimeSlotsModal" />
<!-- todayOverrideDateString="2023-08-06T03:00:00" -->
<time-slot-modal-question
ref="timeSlotModalQuestion"
cmsWidgetName="TimeSlotModalQuestion"
earlyBirdCmsWidgetName="EarlyBirdTimeSlotModal"
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
v-model="selectedTimeSlotId"
@time-slot-modal-closed="timeSlotModalClosed"
:appointmentType="appointmentType"
:mobileEarlyBirdFee="mobileEarlyBirdFee"
:dateAndTimeSlotData="timeSlotsForSelectedDate"
:estimatedServiceMinutesMinimum="selectableDatesData.estimatedServiceMinutesMinimum"
:estimatedServiceMinutesMaximum="selectableDatesData.estimatedServiceMinutesMaximum"
validationRules="time-slot-selection-required" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
ref="funnelFooter"
@ -53,6 +69,7 @@ import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-heade
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form, defineRule } from "vee-validate";
import datePicker from "@/digital-components/date-picker/date-picker";
import timeSlotModalQuestion from "./time-slot-modal-question/time-slot-modal-question";
// Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
@ -72,6 +89,7 @@ import { required } from "@/helpers/validation-rules";
import store from "@/store";
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
defineRule("time-slot-selection-required", required(errorMessages.DATE_REQUIRED));
const getAvailableDates = async (startDate, endDate) => {
// USING DATES PASSED, MAKE AN API CALL
@ -85,11 +103,11 @@ const getAvailableDates = async (startDate, endDate) => {
false
);
const newShopTimeSlots = newShopTimeSlotsResponse.data;
return convertApiResponse(newShopTimeSlots.days);
return convertApiResponse(newShopTimeSlots);
};
const convertApiResponse = (responseData) => {
// DATA CONVERSION
responseData?.forEach((date) => {
responseData?.days.forEach((date) => {
const dateString = date.date;
date.dateString = dateString;
const dateStringPieces = dateString.split("-");
@ -105,8 +123,10 @@ export default {
data() {
return {
selectedDate: null,
selectedTimeSlotId: null,
selectableDatesData: [],
weatherAlerts: [],
mobileEarlyBirdFee: null,
};
},
async beforeRouteEnter(to, from, next) {
@ -135,6 +155,18 @@ export default {
*/
});
// Price EARLY BIRD pre-emptively to allow for asynchronous call to pricing
const pricingPromise = baseMixin.methods.dispatchStoreAction(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: [{ partNumber: "EARLY BIRD" }],
},
false
);
const earlyBirdPromise = baseMixin.methods.dispatchStoreAction(
storeActions.GET_MOBILE_EARLY_BIRD_FEE
);
const alertReasonsPromise = getAlertReasons(store.getters.order.serviceLocation.zipCodeCtu);
// Settle promises and get results
@ -151,6 +183,14 @@ export default {
resultKey: "datePickerInitialData",
promise: datePickerInitialDataPromise,
},
{
resultKey: "earlyBird",
promise: earlyBirdPromise,
},
{
resultKey: "pricingResults",
promise: pricingPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
@ -160,6 +200,10 @@ export default {
vm.setCmsContent(resultMap.cmsContent);
vm.setData(resultMap.alertReasons);
vm.$refs.datePicker.initializeComponent(resultMap.datePickerInitialData);
vm.selectableDatesData = resultMap.datePickerInitialData.initialShopTimeSlotsResponse;
if (resultMap.earlyBird) {
vm.mobileEarlyBirdFee = resultMap.pricingResults[0];
}
});
},
computed: {
@ -173,6 +217,35 @@ export default {
displayWeatherAlert() {
return this.weatherAlerts.length > 0;
},
appointmentType() {
return this.$store.getters.order.serviceLocation.appointmentType;
},
timeSlotsForSelectedDate() {
if (this.selectedDate === null) {
return null;
}
return this.selectableDatesData.days.find(
(selectableDate) => selectableDate.dateString === this.selectedDate.dateString
);
},
appointmentDateAndTime() {
if (!this.selectedTimeSlotId) {
return null;
}
const timeSlotSelectedObject = this.getTimeSlotObjectFromTimeSlotId(
this.selectedTimeSlotId
);
if (timeSlotSelectedObject) {
return {
date: this.selectedDate.dateString,
startTime: timeSlotSelectedObject.startTime,
endTime: timeSlotSelectedObject.endTime,
id: this.selectedTimeSlotId,
};
} else {
return null;
}
},
},
methods: {
doesCopyContainRouterLink,
@ -184,20 +257,12 @@ export default {
// NEED TODO - WHAT ARE PAGE REQ'S FOR THIS PAGE?
},
async getAvailableDatesMethod(startDate, endDate) {
/* TODO - DO WE NEED TO KEEP AN AGGREGATE OF ALL DATES RETURNED
FOR TIMESLOTS in this.selectableDatesData? IS selectableDatesData EVEN NEEDED?
// // ADD API CALL RESULTS TO EXISTING DATE DATA
// this.selectableDatesData = this.selectableDatesData.concat(
// this.convertApiResponse(newShopTimeSlots.days)
// );
// // console.log("this.selectableDatesData is now: ", this.selectableDatesData);
// // RETURN AGGREGATE DATE DATA
// return this.selectableDatesData;
*/
return await getAvailableDates(startDate, endDate);
const newShopTimeSlots = await getAvailableDates(startDate, endDate);
// ADD API CALL RESULTS TO EXISTING DATE DATA
this.selectableDatesData.days = this.selectableDatesData.days.concat(
newShopTimeSlots.days
);
return newShopTimeSlots;
},
setData(alertReasonsData) {
if (alertReasonsData) {
@ -230,6 +295,21 @@ export default {
getServiceZipCtuCodeFromStore() {
return store.getters.order.serviceLocation.zipCodeCtu;
},
openInshopTimeSlotsModal() {
this.$refs["timeSlotModalQuestion"].openModal();
},
getTimeSlotObjectFromTimeSlotId(timeSlotId) {
const timeSlots = this.selectableDatesData.days.find(
(selectableDate) => selectableDate.dateString === this.selectedDate.dateString
).timeSlots;
return timeSlots.find((timeSlot) => timeSlot.id === timeSlotId);
},
timeSlotModalClosed() {
// Clear the selectedDate if no timeslot has been selected
if (!this.selectedTimeSlotId) {
this.selectedDate = null;
}
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
@ -250,10 +330,11 @@ export default {
},
},
watch: {
selectedDate(date) {
/* EXAMPLE RETURNED date OBJECT:
{ "year": 2023, "month": 5, "date": 25, "dateString": "2023-05-25" }
*/
selectedDate(newValue, oldValue) {
// Clear time slot selection if date selected changes
if (newValue !== oldValue) {
this.selectedTimeSlotId = null;
}
},
},
components: {
@ -264,6 +345,7 @@ export default {
Form,
loadingModal,
datePicker,
timeSlotModalQuestion,
},
mounted() {},
};

View file

@ -0,0 +1,264 @@
<template>
<modal
ref="timeSlots"
class="time-slots-modal"
:headerText="dateSelectedReadableDate"
:footerButtonText="footerCloseButtonText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@footer-button-event="closeModal">
<textBlock
v-show="inshopAppointmentDurationTextWithTime"
:customText="inshopAppointmentDurationTextWithTime"
justifyText="center"
typeStyle="small"
class="duration-text-block" />
<buttonQuestion
buttonTypeString="timeslotModalListButton"
:buttonTypeObject="timeslotModalListButton"
:answers="availableTimeSlots"
groupName="ChooseTimeSlot"
textPosition="text-center"
v-model="selectedTimeSlot"
isRequired
validationRules="time-slot-required"
class="mt-5" />
<div
class="mt-1 mb-2"
v-if="supplementalInformationBlock"
v-html="supplementalInformationBlock"></div>
<textBlock
v-show="shouldShowDropoffDisclaimerText"
:customText="dropoffDisclaimerText"
justifyText="left"
typeStyle="caption"
class="mb-2" />
</modal>
</template>
<script>
// Components
import modal from "@/digital-components/modal/modal";
import textBlock from "@/digital-components/text-block/text-block";
import buttonQuestion from "@/digital-components/button-question/button-question";
import timeslotModalListButton from "./timeslot-modal-list-button/timeslot-modal-list-button";
// TODO: Move this somewhere more global
import { DAYS_OF_WEEK, MONTHS_OF_YEAR } from "@/digital-components/date-picker/mixins/constants.js";
import { defineRule, useField } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
// Validation for the modal button
defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
// Constants
const AppointmentTypeStrings = {
IN_SHOP: "Inshop",
MOBILE: "Mobile",
DROP_OFF: "Dropoff",
};
export default {
name: "timeSlotModalQuestion",
props: {
modelValue: Object,
cmsWidgetName: String,
mobileCmsWidgetName: String,
earlyBirdCmsWidgetName: String,
dropoffCmsWidgetName: String,
appointmentType: String,
dateAndTimeSlotData: Object,
mobileEarlyBirdFee: Object,
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
validationRules: String,
},
data() {
return {
selectedTimeSlot: null,
timeslotModalListButton: timeslotModalListButton,
};
},
setup(props) {
const { handleChange } = useField("time-slot-modal-question", props.validationRules);
return {
handleChange,
};
},
watch: {
modelValue() {
this.selectedTimeSlot = this.modelValue;
this.handleChange(this.modelValue);
},
},
computed: {
appointmentDurationTextFromCms() {
return this.getCmsContent(this.cmsWidgetName, "SubheaderText");
},
supplementalInformationBlock() {
let appointmentTypeCmsWidgetName;
let cmsFieldName = "BodyText";
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return null;
} else if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
appointmentTypeCmsWidgetName =
this.selectedTimeSlot === this.earlyBirdButtonText
? this.earlyBirdCmsWidgetName
: this.mobileCmsWidgetName;
} else {
appointmentTypeCmsWidgetName = this.dropoffCmsWidgetName;
if (this.isSameDay) {
cmsFieldName = "BodyText2";
}
}
return this.getCmsContent(appointmentTypeCmsWidgetName, cmsFieldName);
},
footerCloseButtonText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
earlyBirdButtonText() {
return this.getCmsContent(this.earlyBirdCmsWidgetName, "HeaderText");
},
dropoffButtonText() {
return this.getCmsContent(this.dropoffCmsWidgetName, "HeaderText");
},
dropoffDisclaimerText() {
return this.getCmsContent(this.dropoffCmsWidgetName, "FooterText");
},
shouldShowDropoffDisclaimerText() {
return this.appointmentType === AppointmentTypeStrings.DROP_OFF && !this.isSameDay;
},
isSameDay() {
return false;
},
dateSelectedReadableDate() {
if (this.dateAndTimeSlotData === null) {
return null;
}
// This conversion ensures we don't get get GMT induced date changes
const dateObject = new Date(`${this.dateAndTimeSlotData.dateString}T00:00:00`);
const weekdayName = DAYS_OF_WEEK[dateObject.getDay()];
const month = MONTHS_OF_YEAR[dateObject.getMonth()];
const numberDayOfMonth = dateObject.getDate();
// Ex. Tuesday, April 23
return `${weekdayName}, ${month} ${numberDayOfMonth}`;
},
inshopAppointmentDurationTextWithTime() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return null;
}
let durationSubHeaderText = this.appointmentDurationTextFromCms + " ";
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
durationSubHeaderText += "All day";
} else {
durationSubHeaderText += this.getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
}
return durationSubHeaderText;
},
availableTimeSlots() {
if (this.dateAndTimeSlotData === null) {
return null;
}
let availableTimeSlots;
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
availableTimeSlots = [
{
value: this.dateAndTimeSlotData.timeSlots[0],
buttonLabel: this.dropoffButtonText,
},
];
} else {
availableTimeSlots = this.dateAndTimeSlotData.timeSlots.map((timeslot) => {
let readableTime;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
readableTime = this.getDisplayTextForMilitaryTime(timeslot.startTime);
} else if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
readableTime = `${this.getDisplayTextForMilitaryTime(
timeslot.startTime
)} - ${this.getDisplayTextForMilitaryTime(timeslot.endTime)}`;
}
return {
value: timeslot.id,
buttonLabel: readableTime,
};
});
}
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
const offerPremium = this.dateAndTimeSlotData.timeSlots[0].offerPremium;
const hasEarlyBird = this.mobileEarlyBirdFee?.partType === "EARLY BIRD";
if (offerPremium && hasEarlyBird) {
availableTimeSlots.unshift({
value: this.dateAndTimeSlotData.timeSlots[0].id + "-earlybird",
buttonLabel: this.earlyBirdButtonText,
buttonLabelSubCopy: this.getTotalLineItemPrice(this.mobileEarlyBirdFee),
});
}
}
return availableTimeSlots;
},
},
methods: {
openModal() {
this.$refs["timeSlots"].openModal();
},
// fires any time the footer button is used, is fired before "onModalClosed"
closeModal() {
this.$emit("update:modelValue", this.selectedTimeSlot);
this.$refs["timeSlots"].closeModal();
},
// fires any time the modal is closed, AFTER "closeModal" fires if footer button is used
onModalClosed() {
this.selectedTimeSlot = this.modelValue;
this.$emit("time-slot-modal-closed");
},
// Expected input: "HH:MM:SS"
getDisplayTextForMilitaryTime(militaryTimeInput) {
let hours = parseInt(militaryTimeInput.split(":")[0]);
const minutes = militaryTimeInput.split(":")[1];
let meridianNotation = "AM";
if (militaryTimeInput.split(":")[0] > 12) {
hours -= 12;
meridianNotation = "PM";
}
return `${hours}:${minutes} ${meridianNotation}`;
},
getDisplayTextForDurationLength(durationMinimum, durationMaximum) {
let displayTextForDurationLength;
if (durationMaximum >= 120) {
displayTextForDurationLength = `${durationMinimum / 60} - ${
durationMaximum / 60
} hours`;
} else {
displayTextForDurationLength = `${durationMinimum} - ${durationMaximum} minutes`;
}
return displayTextForDurationLength;
},
},
components: {
modal,
textBlock,
buttonQuestion,
},
};
</script>
<style lang="scss">
.time-slots-modal.modal.modal-component {
> .modal-dialog > .modal-content {
margin-top: 24px;
}
.modal-header {
padding-bottom: 0;
margin-bottom: 0 !important;
}
.text-block.duration-text-block {
margin-top: 0 !important;
}
}
</style>

View file

@ -0,0 +1,119 @@
<template>
<baseInputButton
v-bind="$props"
buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2"
v-model="selectedValue">
<div
:aria-label="buttonLabel"
class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4">
<span class="m-0" :class="textPosition">
{{ buttonLabel }}
</span>
<span v-if="buttonLabelSubCopy" class="m-0 small" :class="textPosition">
{{ formattedButtonLabelSubCopy }}
</span>
<span v-if="screenReaderOnlyText" class="sr-only">
{{ screenReaderOnlyText }}
</span>
<loader
v-if="isLoaderDisplayed && selectingInitiatesLoad"
:class="[this.loaderColor, this.loaderPosition]" />
</div>
</baseInputButton>
</template>
<script>
import loader from "@/ux-components/loader/loader";
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
export default {
name: "timeslotMOdalListButton",
mixins: [inputButtonWrapperMixin],
props: {
loaderColor: String,
loaderPosition: {
type: String,
default: "right",
},
},
data() {
return {
isLoaderDisplayed: false,
};
},
computed: {
formattedButtonLabelSubCopy() {
return this.buttonLabelSubCopy?.toFixed(2);
},
},
methods: {
displayLoader() {
this.isLoaderDisplayed = true;
},
preHandleAnswerChange() {
if (this.selectingInitiatesLoad) {
this.displayLoader();
}
},
},
components: {
loader,
baseInputButton,
},
};
</script>
<style lang="scss" scoped>
.loader {
position: absolute;
}
.list-button {
outline: none;
input[type="radio"],
input[type="checkbox"] {
position: static; //override bootstrap
&:focus-visible + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content {
color: $black;
font-weight: 500;
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
}
&:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content p,
&:checked + .list-button-content span {
font-weight: 500;
}
&:checked + .list-button-content span:nth-child(2) {
font-weight: 400;
color: $gray-600;
}
}
}
.list-button-content {
color: $gray-600;
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span {
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
}
</style>

View file

@ -1150,7 +1150,18 @@ export const actions = {
// logApiCall: false,
// });
},
getMobileEarlyBirdFee(context) {
const damageType = context.getters.damage.isRepair ? "Repair" : "Replace";
const paymentType = context.getters.order.payment.isInsurance ? "Insurance" : "Cash";
return globalMethods.callMockHttpClient({
method: endpoints.GetMobileEarlyBirdFee.method,
endpoint: `${endpoints.GetMobileEarlyBirdFee.mockUrl}/Cash/Replace`,
});
// return globalMethods.callHttpClient({
// method: endpoints.GetMobileEarlyBirdFee.method,
// endpoint: `${endpoints.GetMobileEarlyBirdFee.url}/${paymentType}/${damageType}`,
// });
},
// Session API Actions
saveSession(context) {
const vehicle = context.getters.vehicle;