CASH-1535: Mobile First Appointment

This commit is contained in:
Chris Redelinghuys 2025-10-30 10:08:30 -04:00
parent 169d8b8949
commit 387906baa8
5 changed files with 348 additions and 3 deletions

View file

@ -4,6 +4,7 @@ const experimentUniverses = {
MSR: "MSR",
IGQ_SkipQuote: "NextGen_IGQSkipToInsurance",
AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay",
MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment",
};
const experimentSettings = {
@ -28,6 +29,10 @@ const experimentSettings = {
DISPLAY_AFTERPAY_BREAKOUT_DISPLAY: "Display_AfterpayBreakoutDisplay",
AFTERPAY_EXTENDED_PAY_OPTION_THRESHOLD: "AfterPayExtendedPayOptionThreshold",
DYNAMO_LOGGING: "DynamoLogging",
SHOW_MOBILE_FIRST_APPT: "ShowMobileFirstAppt",
SHOW_PM_MOBILE_DAYS: "Show_PMmobileDays",
SHOW_NO_PM_MOBILE_DAYS: "Show_NoPMmobileDays",
SHOW_NO_MOBILE_AVAILABLE_DAYS: "Show_NoMobileAvailableDays",
};
const experimentTriggers = {

View file

@ -14,6 +14,7 @@
<div class="modal-dialog" :class="{ 'modal-dialog-centered': !isRecal }">
<div class="modal-content">
<div class="modal-header mb-0 mt-6">
<slot name="modal-header-slot"></slot>
<label
v-if="headerText"
class="modal-title d-flex justify-content-center pb-0 w-100">

View file

@ -0,0 +1,214 @@
<template>
<div id="mobile-first-modal-container">
<modal
:ref="modalName"
:headerText="modalHeaderText"
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:isFooterButtonPrimary="true"
@footer-button-event="confirmAppointment">
<p class="modal-body-inner" v-html="modalBodyText"></p>
<template v-slot:modal-header-slot>
<span>{{ modalSubHeaderText }}</span>
</template>
<template v-slot:modal-footer-slot>
<button
type="button"
class="btn btn-link"
id="see-more-options"
:disabled="isLoading"
@click="closeModal">
{{ buttonText }}
</button>
</template>
</modal>
</div>
</template>
<script>
// Supporting files
import store from "@/store";
import modal from "@/digital-components/modal/modal";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import { get12HourTimeFormat } from "@/helpers/date-helper";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
const INLINE_SERVICETYPE_TOKEN = "custom:serviceType";
const INLINE_DAY_TOKEN = "custom:day";
const INLINE_DATE_TOKEN = "custom:date";
const INLINE_TIMESLOT_TOKEN = "custom:timeslot";
const INLINE_SERVICE_LENGTH_TOKEN = "custom:serviceLength";
export default {
name: "mobile-first-modal",
props: {
cmsWidgetName: String,
modalWidgetName: String,
},
data() {
return {
selectedAppontment: {
estimatedServiceMinutes: {
minimum: null,
maximum: null,
},
timeSlot: null,
date: null,
},
};
},
methods: {
splitCopyOnCMSPlaceHolder,
get12HourTimeFormat,
openModal() {
this.modal.openModal();
},
onModalClosed() {
this.$emit("close-mobile-first-modal");
},
closeModal() {
this.modal.closeModal();
},
confirmAppointment() {
let selectedTimeSlot = {
timeSlot: this.selectedAppontment.timeSlot,
routeCode: this.selectedAppontment.timeSlot.id,
};
this.$emit("confirm-appointment", selectedTimeSlot);
this.modal.closeModal();
},
setSelectedAppointment(appointment) {
this.selectedAppontment = appointment;
},
},
computed: {
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
modalSubHeaderText() {
return this.getCmsContent(this.modalWidgetName, "SubheaderText");
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText");
},
modalBodyText() {
var tokens = this.splitCopyOnCMSPlaceHolder(
this.getCmsContent(this.modalWidgetName, "BodyText")
);
tokens.forEach((token) => {
if (token.includes(INLINE_SERVICETYPE_TOKEN)) {
const serviceType = "<b>" + this.getServiceType + "</b>";
tokens.splice(tokens.indexOf(token), 1, serviceType);
} else if (token.includes(INLINE_DAY_TOKEN) && this.selectedAppontment.date) {
const day =
"<b>" +
new Date(this.selectedAppontment.date).toLocaleDateString("en-US", {
weekday: "long",
timeZone: "UTC",
}) +
"</b>";
tokens.splice(tokens.indexOf(token), 1, day);
} else if (token.includes(INLINE_DATE_TOKEN) && this.selectedAppontment.date) {
const date = new Date(this.selectedAppontment.date).toLocaleDateString(
"en-US",
{ timeZone: "UTC", weekday: "long", month: "long", day: "numeric" }
);
tokens.splice(tokens.indexOf(token), 1, date);
} else if (
token.includes(INLINE_TIMESLOT_TOKEN) &&
this.selectedAppontment.timeSlot
) {
const timeslot =
this.get12HourTimeFormat(this.selectedAppontment.timeSlot.startTime) +
" - " +
this.get12HourTimeFormat(this.selectedAppontment.timeSlot.endTime);
tokens.splice(tokens.indexOf(token), 1, timeslot);
} else if (
token.includes(INLINE_SERVICE_LENGTH_TOKEN) &&
this.selectedAppontment.estimatedServiceMinutes
) {
const inshopDurationTime = getDisplayTextForDurationLength(
this.selectedAppontment.estimatedServiceMinutes.minimum,
this.selectedAppontment.estimatedServiceMinutes.maximum
);
tokens.splice(tokens.indexOf(token), 1, inshopDurationTime);
}
});
return tokens.join("");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
buttonText() {
return this.getCmsContent(this.modalWidgetName, "FooterText2");
},
getServiceType() {
return store.getters.order.damage.isRepair ? "repair" : "replace";
},
},
components: { modal },
};
</script>
<style lang="scss">
#mobile-first-modal-container {
.modal-component {
@include media-breakpoint-up(md) {
.modal-dialog {
left: 0;
align-content: center;
flex-wrap: wrap;
width: 22.063rem;
transform: translate(0, 0);
.modal-content {
border-radius: $border-radius-lg;
}
}
}
.modal-dialog {
.modal-content {
.modal-header {
flex-direction: column;
& > span {
color: $red;
font-size: $font-size-14;
font-weight: $font-weight-600;
text-transform: uppercase;
}
.modal-title {
font-size: $font-size-20;
font-weight: $font-weight-normal;
}
}
.modal-body {
padding: 0.5rem 1rem;
.modal-body-inner {
ul {
list-style: none;
padding: 1rem;
background-color: #f4f4f4;
font-size: $font-size-14;
}
}
}
.modal-footer {
flex-flow: wrap-reverse;
#see-more-options {
width: 100%;
text-decoration: none;
margin-top: 1.5rem;
font-weight: $font-weight-600;
}
}
}
}
}
}
</style>

View file

@ -32,6 +32,10 @@
</div>
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<mobileFirstModal
modalWidgetName="MobileFirstModalWidget"
ref="mobileFirstModal"
@confirm-appointment="updateTimeSlotandNavigateForward" />
<alert
ref="alertMobileOnly"
class="mt-5 mb-5"
@ -183,6 +187,7 @@ import shopListButton from "@/layouts/service-location/shop-question/shop-list-b
import shopLocation from "@/layouts/schedule/shop-location/shop-location";
import timeSlotQuestion from "@/layouts/schedule/time-slot-question/time-slot-question.vue";
import durationTextBlock from "@/layouts/schedule/duration-text-block/duration-text-block.vue";
import mobileFirstModal from "@/layouts/schedule/mobile-first-modal/mobile-first-modal.vue";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
import shopQuestionPopup from "@/layouts/service-location/shop-question/shop-question-popup";
@ -203,7 +208,7 @@ import mobileFeeWaiverAlert from "./mobile-fee-waiver-alert/mobile-fee-waiver-al
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { experimentUniverses, experimentSettings } from "@/constants/experiments";
import {
AppointmentTypeStrings,
RouteCodeFlags,
@ -242,6 +247,11 @@ import { getAmountDue, getPricingByDayPartWithPrice } from "@/helpers/pricing-he
import { getItemsWithoutRecalParts } from "@/helpers/recal-helper";
import { deepClone } from "@/helpers/object-helper";
import { debugLog } from "@/helpers/debug-log-helper";
import {
getSessionKeyValue,
getUserIdValue,
getDeviceIdValue,
} from "@/helpers/heritage-integration/cookie-helper";
// DEFINE VALIDATION RULES
defineRule("mobile-location-required", (value) => {
@ -1172,6 +1182,8 @@ export default {
moreShopTimeSlots.mobileTimeSlotsData.days
);
this.showMobileFirstModal();
return moreShopTimeSlots;
},
@ -1699,6 +1711,7 @@ export default {
}
this.appointmentType = AppointmentTypeStrings.MOBILE;
this.updateSelectedProvider();
this.showMobileFirstModal();
} else if (newAppointmentType) {
if (this.appointmentType != AppointmentTypeStrings.MOBILE) {
// Clear last shop selected if appointment type was changed in any manner other than from Mobile
@ -1723,6 +1736,116 @@ export default {
setSelectedDateToFirstAvailable() {
this.selectedDate = this.getFirstAvailableDate();
},
showMobileFirstModal() {
if (this.appointmentType == AppointmentTypeStrings.MOBILE) {
const isShowMobileFirstAppt = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MOBILE_FIRST_APPT,
"true"
);
const pmMobileDays = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_PM_MOBILE_DAYS
);
const noPMMobileDays = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_PM_MOBILE_DAYS
);
const noMobileAvailableDays = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_MOBILE_AVAILABLE_DAYS
);
let preSelectedMobileAppointment = null;
const todaysDate = getTodayDate();
let firstMobilePMAppt = this.getFirstAvailableMobileApptByTOD("PM");
if (!firstMobilePMAppt) {
return;
}
let firstMobilePMDate = firstMobilePMAppt ? firstMobilePMAppt.date : null;
let numberOfDaysToFirstMobilePMDate =
(new Date(firstMobilePMDate) - todaysDate) / (1000 * 60 * 60 * 24);
const shouldExposeMobileFirstAppointment = () => {
return (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= noMobileAvailableDays
);
};
if (shouldExposeMobileFirstAppointment()) {
const mobileFirstExperiment = store.getters.applicationUser.experiments.find(
(e) => e.universeName === experimentUniverses.MOBILE_FIRST_APPOINTMENT
);
const hasExposedMobileFirst = mobileFirstExperiment?.isExposed;
if (!hasExposedMobileFirst && mobileFirstExperiment) {
baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.LOG_EXPERIMENT_EXPOSURE_AND_UPDATE_STORE,
{
userId: getUserIdValue(),
deviceId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: "schedule",
experiment: mobileFirstExperiment,
},
"schedule",
false
);
if (isShowMobileFirstAppt) {
if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= pmMobileDays
) {
preSelectedMobileAppointment = firstMobilePMAppt;
} else if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate >= noPMMobileDays
) {
let firstMobileAMAppt = this.getFirstAvailableMobileApptByTOD("AM");
preSelectedMobileAppointment = firstMobileAMAppt
? firstMobileAMAppt
: null;
!preSelectedMobileAppointment &&
(preSelectedMobileAppointment = firstMobilePMAppt);
}
this.$refs.mobileFirstModal.setSelectedAppointment(
preSelectedMobileAppointment
);
this.$refs.mobileFirstModal.openModal();
}
}
}
}
},
getFirstAvailableMobileApptByTOD(timeOfDay) {
if (!this.selectableDatesMobile?.days?.length) {
return null;
} else {
for (const dateObj of this.selectableDatesMobile.days) {
let matchingTimeSlot = {
estimatedServiceMinutes: {
minimum: this.estimatedServiceMinutesMinimum,
maximum: this.estimatedServiceMinutesMaximum,
},
timeSlot: null,
date: null,
};
const isMatchingApptDay = dateObj.timeSlots.some(
(slot) =>
slot.id.includes(timeOfDay) &&
(matchingTimeSlot.timeSlot = slot) &&
(matchingTimeSlot.date = dateObj.date)
);
if (isMatchingApptDay && matchingTimeSlot) {
return matchingTimeSlot;
}
}
return null;
}
},
updateTimeSlotandNavigateForward(timeSlotObj) {
this.updateTimeSlot(timeSlotObj);
this.forwardButtonAction();
},
},
watch: {
appointmentTypeFromAppointmentTypeQuestion: {
@ -1767,6 +1890,7 @@ export default {
locationAlerts,
textBlock,
timeSlotQuestion,
mobileFirstModal,
alert,
serviceZipModalQuestion,

View file

@ -123,14 +123,15 @@ $body-color: $gray-600;
//Fonts
$font-family-sans-serif: UrbanistRegular, Arial, Helvetica, sans-serif;
$font-family-monospace: UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
monospace;
$font-family-monospace:
UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
// stylelint-enable value-keyword-case
$font-family-base: $font-family-sans-serif;
$font-family-code: $font-family-monospace;
$font-size-base: 1rem; // Assumes the browser default, typically `16px`
$font-size-12: $font-size-base * 0.75; // 12px
$font-size-14: $font-size-base * 0.875; // 14px
$font-size-20: $font-size-base * 1.25; // 20px
//Custom Font size (extra small)