CSR-1137 | All timeslot implementations working

This commit is contained in:
Scott Kiener 2023-05-17 14:08:16 -04:00
parent 6ca3d916ba
commit 23711787dd
10 changed files with 343 additions and 52 deletions

View file

@ -103,6 +103,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

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

View file

@ -176,6 +176,7 @@ export default {
getComponentLoopWrapperClasses() {
let classes;
switch (this.buttonTypeString) {
case "timeSlotModalListButton":
case "listButton":
classes = "w-100";
break;

View file

@ -56,13 +56,6 @@ export default {
onModalClosedCallback: {
type: Function,
},
allowManualFooterDisableOverride: {
type: Boolean,
default: false,
},
isFooterDisabledManualOverride: {
type: Boolean,
},
},
setup() {
const modalId = `modal-${crypto.randomUUID()}`;
@ -105,13 +98,10 @@ export default {
},
computed: {
isFooterButtonDisabled() {
if (this.allowManualFooterDisableOverride !== null) {
return this.isFooterDisabledManualOverride;
} else if (!this.meta.touched) {
if (!this.meta.touched) {
return !this.meta.valid;
} else {
return !this.meta.dirty || !this.meta.valid;
}
}
return !this.meta.dirty || !this.meta.valid;
},
},
components: {

View file

@ -1,6 +1,6 @@
<template>
<div
class="text-block w-100 mt-2 d-flex"
class="text-block w-100 mt-2"
:class="[justifyText, typeStyle, fontWeight]"
v-html="this.TextBlockCopy"></div>
</template>
@ -28,6 +28,7 @@ export default {
<style lang="scss" scoped>
.text-block {
display: flex;
&.left {
justify-content: flex-start;
}

View file

@ -26,10 +26,20 @@
<time-slot-modal-question
ref="timeSlotModalQuestion"
cmsWidgetName="TimeSlotModalQuestion"
earlyBirdCmsWidgetName="EarlyBirdTimeSlotModal"
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
v-model="selectedTimeSlotId"
:dateAndTimeSlotData="TimeSlotsForSelectedDate"
:appointmentType="appointmentType"
:mobileEarlyBirdFee="mobileEarlyBirdFee"
:dateAndTimeSlotData="timeSlotsForSelectedDate"
:estimatedServiceMinutesMinimum="selectableDatesData.estimatedServiceMinutesMinimum"
:estimatedServiceMinutesMaximum="selectableDatesData.estimatedServiceMinutesMaximum"/>
:estimatedServiceMinutesMaximum="
selectableDatesData.estimatedServiceMinutesMaximum"
validationRules="time-slot-selection-required"/>
<button @click="setAppointmentType('Inshop')">Set to Inshop</button>
<button @click="setAppointmentType('Mobile')">Set to Mobile</button>
<button @click="setAppointmentType('Dropoff')">Set to Dropoff</button>
<button @click="openInshopTimeSlotsModal">Click for timeslots</button>
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
@ -67,6 +77,7 @@ import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
defineRule("time-slot-selection-required", required(errorMessages.DATE_REQUIRED));
export default {
name: "schedule",
@ -75,11 +86,27 @@ export default {
selectedDate: null,
selectedTimeSlotId: null,
selectableDatesData: [],
mobileEarlyBirdFee: null,
TEMPORARYappointmentType: "Inshop",
};
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const earlyBirdPromise = baseMixin.methods.dispatchStoreAction(
storeActions.GET_MOBILE_EARLY_BIRD_FEE
);
// 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
// );
// Settle promises and get results
const promiseResultMap = [
@ -87,6 +114,14 @@ export default {
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "earlyBird",
promise: earlyBirdPromise,
},
// {
// resultKey: "pricingResults",
// promise: pricingPromise,
// },
];
const resultMap = await settleAllPromises(promiseResultMap);
@ -94,6 +129,13 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.mobileEarlyBirdFee = resultMap.earlyBird;
vm.mobileEarlyBirdFee.laborAmount = 15;
vm.mobileEarlyBirdFee.sellingPrice = 0;
vm.mobileEarlyBirdFee.kitPrice = 0;
// if (resultMap.earlyBird) {
// vm.mobileEarlyBirdFee = resultMap.pricingResults;
// }
});
},
computed: {
@ -104,17 +146,25 @@ export default {
// Splits content when brackets are found in text so that text can be looped through and router-link can be injected when needed
return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
},
TimeSlotsForSelectedDate() {
appointmentType() {
return this.TEMPORARYappointmentType;
//return this.$store.getters.order.serviceLocation.appointmentType;
},
timeSlotsForSelectedDate() {
if (this.selectedDate === null) {
return null;
}
return this.selectableDatesData.find(selectableDate => selectableDate.dateString === this.selectedDate.dateString);
return this.selectableDatesData.find(
(selectableDate) => selectableDate.dateString === this.selectedDate.dateString
);
},
appointmentDateAndTime() {
if (!this.selectedTimeSlotId) {
return null;
}
const timeSlotSelectedObject = this.getTimeSlotObjectFromTimeSlotId(this.selectedTimeSlotId);
const timeSlotSelectedObject = this.getTimeSlotObjectFromTimeSlotId(
this.selectedTimeSlotId
);
if (timeSlotSelectedObject) {
return {
date: this.selectedDate.dateString,
@ -125,7 +175,6 @@ export default {
} else {
return null;
}
},
},
methods: {
@ -138,6 +187,7 @@ export default {
// NEED TODO - WHAT ARE PAGE REQ'S FOR THIS PAGE?
},
async getAvailableDates(startDate, endDate) {
console.log('calling getAvailableDates', startDate, endDate);
// USING DATES PASSED, MAKE AN API CALL
const newShopTimeSlotsResponse = await baseMixin.methods.dispatchStoreAction(
storeActions.GET_SHOP_TIME_SLOTS,
@ -151,13 +201,14 @@ export default {
const newShopTimeSlots = newShopTimeSlotsResponse.data;
// console.log("newShopTimeSlots ", newShopTimeSlots)
// ADD API CALL RESULTS TO EXISTING DATE DATA
this.selectableDatesData = this.selectableDatesData.concat(
this.convertApiResponse(newShopTimeSlots.days)
);
this.selectableDatesData.estimatedServiceMinutesMaximum = newShopTimeSlots.estimatedServiceMinutesMaximum;
this.selectableDatesData.estimatedServiceMinutesMinimum = newShopTimeSlots.estimatedServiceMinutesMinimum;
this.selectableDatesData.estimatedServiceMinutesMaximum =
newShopTimeSlots.estimatedServiceMinutesMaximum;
this.selectableDatesData.estimatedServiceMinutesMinimum =
newShopTimeSlots.estimatedServiceMinutesMinimum;
console.log("this.selectableDatesData is now: ", this.selectableDatesData);
// RETURN AGGREGATE DATE DATA
@ -175,12 +226,17 @@ export default {
});
return responseData;
},
setAppointmentType(appointmentType) {
this.TEMPORARYappointmentType = appointmentType;
},
openInshopTimeSlotsModal() {
this.$refs["timeSlotModalQuestion"].openModal();
},
getTimeSlotObjectFromTimeSlotId(timeSlotId) {
const timeSlots = this.selectableDatesData.find(selectableDate => selectableDate.dateString === this.selectedDate.dateString).timeSlots;
return timeSlots.find(timeSlot => timeSlot.id === timeSlotId);
const timeSlots = this.selectableDatesData.find(
(selectableDate) => selectableDate.dateString === this.selectedDate.dateString
).timeSlots;
return timeSlots.find((timeSlot) => timeSlot.id === timeSlotId);
},
backButtonAction() {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);

View file

@ -4,23 +4,32 @@
class="time-slots-modal"
:headerText="dateSelectedReadableDate"
:footerButtonText="footerCloseButtonText"
allowManualFooterDisableOverride
:isFooterDisabledManualOverride="selectedTimeSlot === null"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@footer-button-event="closeModal">
<textBlock
:customText="appointmentDurationTextWithTime"
v-show="inshopAppointmentDurationTextWithTime"
:customText="inshopAppointmentDurationTextWithTime"
justifyText="center"
typeStyle="small"
class="mb-5" />
class="duration-text-block" />
<buttonQuestion
class="radioQuestion"
buttonTypeString="timeslotModalListButton"
:buttonTypeObject="timeslotModalListButton"
:answers="availableTimeSlots"
groupName="ChooseTimeSlot"
textPosition="text-center"
v-model="selectedTimeSlot"
isRequired />
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>
@ -29,54 +38,116 @@
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";
import { daysOfWeek, monthsOfYear } from "@/constants/scheduling.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 dateObject = new Date(`${this.dateAndTimeSlotData.dateString}T00:00:00`);
const weekdayName = daysOfWeek[dateObject.getDay()];
const month = monthsOfYear[dateObject.getMonth()];
const numberDayOfMonth = dateObject.getDate();
// Ex. Tuesday, April 23
return `${weekdayName}, ${month} ${numberDayOfMonth}`;
},
appointmentDurationTextWithTime() {
inshopAppointmentDurationTextWithTime() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return null;
}
let durationSubHeaderText = this.appointmentDurationTextFromCms + " ";
if (this.estimatedServiceMinutesMaximum >= 120) {
durationSubHeaderText += `${this.estimatedServiceMinutesMinimum / 60} - ${
this.estimatedServiceMinutesMaximum / 60} hours`;
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
durationSubHeaderText += "All day";
} else {
durationSubHeaderText += `${this.estimatedServiceMinutesMinimum} - ${this.estimatedServiceMinutesMaximum} minutes`
durationSubHeaderText += this.getDisplayTextForDurationLength(this.estimatedServiceMinutesMinimum, this.estimatedServiceMinutesMaximum);
}
return durationSubHeaderText;
},
@ -84,20 +155,33 @@ export default {
if (this.dateAndTimeSlotData === null) {
return null;
}
return this.dateAndTimeSlotData.timeSlots.map(timeslot => {
let hours = timeslot.startTime.split(":")[0];
let minutes = timeslot.startTime.split(":")[1];
let meridianNotation = "AM";
if (timeslot.startTime.split(":")[0] > 12) {
hours -= 12;
meridianNotation = "PM";
let availableTimeSlots;
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
availableTimeSlots = [{value: "Dropoff", 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)});
}
let readableTime = `${hours}:${minutes} ${meridianNotation}`;
return {
value: timeslot.id,
buttonLabel: readableTime,
};
});
}
return availableTimeSlots;
},
},
methods: {
@ -113,6 +197,28 @@ export default {
onModalClosed() {
this.selectedTimeSlot = this.modelValue;
},
// Expected input: "HH:MM:SS"
getDisplayTextForMilitaryTime(militaryTimeInput) {
let hours = 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,
@ -131,7 +237,7 @@ export default {
padding-bottom: 0;
margin-bottom: 0 !important;
}
.text-block {
.text-block.duration-text-block {
margin-top: 0 !important;
}
}

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

@ -16,6 +16,7 @@ export default {
},
methods: {
setCmsContent(cmsContent) {
console.log(cmsContent);
this.$root.cmsContentByWidget = cmsContent;
},
getCmsContent(widgetName, fieldName) {

View file

@ -1033,7 +1033,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;