Merge branch 'develop' into feature/CASH-836

This commit is contained in:
maguire-arman 2025-07-14 14:36:26 -04:00
commit 60c6a1cdc7
56 changed files with 6696 additions and 964 deletions

View file

@ -1,6 +1,6 @@
module.exports = {
verbose: true,
coverageReporters: ["html", "text", "jest-junit", "cobertura"],
coverageReporters: ["html", "text", "jest-junit", "cobertura"], reporters: ['default', 'jest-junit'],
testResultsProcessor: "jest-junit",
preset: "@vue/cli-plugin-unit-jest",
transform: { "^.+\\.vue$": "@vue/vue3-jest",
@ -31,6 +31,9 @@ module.exports = {
"!src/layouts/payment-pia-return/*.vue", // Temp test exclusion while in development
"!src/layouts/insurance/*.vue", // Temp test exclusion while in development
"!src/layouts/insurance-company/*.vue", // Temp test exclusion while in development
"!src/**/*-june-2025.vue", // Exclude these temporary files for CASH-845 project
"!src/layouts/insurance-company/insurance-company-question/*.vue", // Temp test exclusion while in development
"!src/experiment-components/*.vue",
// END

View file

@ -7,6 +7,7 @@
"serve": "npx prettier --write src && vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit --coverage --ci",
"test:unit:coverage": "vue-cli-service test:unit --coverage --ci",
"test:unit:lite": "vue-cli-service test:unit --ci",
"lint": "vue-cli-service lint"
},

View file

@ -12,7 +12,7 @@ body {
padding: 8px;
}
body h3 {
font-weight: 600;
font-weight: 700;
}
body .validation-info {
display: none;
@ -35,7 +35,7 @@ body .form-group {
body .headerlinecontainer .headerline1 {
font-size: 20px;
line-height: 32px;
font-weight: 500;
font-weight: 400;
margin: 24px 0;
text-align: center;
}
@ -51,6 +51,8 @@ body select {
font-size: 16px;
color: #000;
background-color: #fff;
font-family: Urbanist, Arial, Helvetica, sans-serif;
font-weight: 400;
}
body input:focus, body input:focus-visible,
body select:focus,
@ -96,7 +98,7 @@ body .creditCardSpecific div .headerlinecontainer {
display: none;
}
body .creditCardSpecific div label {
font-weight: 600;
font-weight: 500;
}
body .creditCardSpecific #infoRow2 {
margin-bottom: 0;
@ -140,6 +142,7 @@ body .buttonContainer button {
border-radius: 8px;
color: #fff;
justify-content: center;
font-family: Urbanist, Arial, Helvetica, sans-serif;
font-weight: 500;
font-size: 16px;
height: 48px;
@ -272,7 +275,7 @@ body .cc-error-container #alert-message {
#fmg-checkout-shared #other-payments-container label span.paymentOptionText {
margin: auto 0;
padding-left: 0;
font-weight: 600;
font-weight: 400;
}
#fmg-checkout-shared #other-payments-container #afterpayParentLink label > span:first-of-type {
width: 100%;

View file

@ -12,7 +12,7 @@ body {
padding: 8px;
h3 {
font-weight: 600;
font-weight: 700;
}
.validation-info {
@ -42,7 +42,7 @@ body {
.headerline1 {
font-size: 20px;
line-height: 32px;
font-weight: 500;
font-weight: 400;
margin: 24px 0;
text-align: center;
}
@ -61,6 +61,8 @@ body {
font-size: 16px;
color: #000;
background-color: #fff;
font-family: Urbanist, Arial, Helvetica, sans-serif;
font-weight: 400;
&:focus,
&:focus-visible {
box-shadow: 0 0 0 2.5px #1574a1;
@ -103,7 +105,7 @@ body {
display: none;
}
label {
font-weight: 600;
font-weight: 500;
}
}
#infoRow2 {
@ -151,6 +153,7 @@ body {
border-radius: 8px;
color: #fff;
justify-content: center;
font-family: Urbanist, Arial, Helvetica, sans-serif;
font-weight: 500;
font-size: 16px;
height: 48px;
@ -330,7 +333,7 @@ body {
&.paymentOptionText {
margin: auto 0;
padding-left: 0;
font-weight: 600;
font-weight: 400;
}
}
}

View file

@ -176,6 +176,10 @@ const endpoints = {
url: "/analytics/api/v1/analytics/log-part-questions",
method: "POST",
},
LogDigitalConsumer: {
url: "/analytics/api/v1/analytics/digitalconsumer-log",
method: "POST",
},
GetExperimentsByUser: {
url: "/analytics/api/v1/analytics/get-experiments",
method: "GET",

View file

@ -26,6 +26,8 @@ const experimentSettings = {
FOSTER_LOVE: "DisplayFosterLove",
SKIP_TO_INSURANCE: "SkipToInsurance",
DISPLAY_AFTERPAY_BREAKOUT_DISPLAY: "Display_AfterpayBreakoutDisplay",
AFTERPAY_EXTENDED_PAY_OPTION_THRESHOLD: "AfterPayExtendedPayOptionThreshold",
DYNAMO_LOGGING: "DynamoLogging",
};
const experimentTriggers = {

View file

@ -13,8 +13,10 @@ const pagePercentageMapper = {
"capability-questions": 40,
quote: 48,
"insurance-company": 52,
"service-location": 60,
schedule: 72,
// TODO: REMOVE THIS COMMENT AND BELOW, ONCE CASH-803 (SERVICE-LOCATION AND SCHEDULE PAGE COMBINATION) HAS BEEN VETTED
// "service-location": 60,
schedule: 64,
"mobile-details": 76,
"customer-details": 84,
"payment-method": 92,
payment: 96,

View file

@ -16,10 +16,19 @@ const RouteCodeFlags = {
OVERNIGHT_DROP_OFF: "OVERNIGHT DROP OFF",
};
const cmsWidgetFieldMappings = {
MODAL_CLOSE_BUTTON: "FooterText",
SUPPLEMENTAL_INFORMATION: "BodyText",
TIME_SLOT_BUTTON: "HeaderText",
DISCLAIMER: "FooterText",
DURATION: "SubheaderText",
};
export {
AppointmentTypeStrings,
PREMIUM_TIME_SLOT_ID_FLAG,
PREMIUM_FEE_PART_TYPE,
PRICING_BY_DAY_PART_TYPE,
RouteCodeFlags,
cmsWidgetFieldMappings,
};

View file

@ -0,0 +1,54 @@
const stateOptions = {
AL: "Alabama",
AK: "Alaska",
AZ: "Arizona",
AR: "Arkansas",
CA: "California",
CO: "Colorado",
CT: "Connecticut",
DE: "Delaware",
DC: "District Of Columbia",
FL: "Florida",
GA: "Georgia",
HI: "Hawaii",
ID: "Idaho",
IL: "Illinois",
IN: "Indiana",
IA: "Iowa",
KS: "Kansas",
KY: "Kentucky",
LA: "Louisiana",
ME: "Maine",
MD: "Maryland",
MA: "Massachusetts",
MI: "Michigan",
MN: "Minnesota",
MS: "Mississippi",
MO: "Missouri",
MT: "Montana",
NE: "Nebraska",
NV: "Nevada",
NH: "New Hampshire",
NJ: "New Jersey",
NM: "New Mexico",
NY: "New York",
NC: "North Carolina",
ND: "North Dakota",
OH: "Ohio",
OK: "Oklahoma",
OR: "Oregon",
PA: "Pennsylvania",
RI: "Rhode Island",
SC: "South Carolina",
SD: "South Dakota",
TN: "Tennessee",
TX: "Texas",
UT: "Utah",
VT: "Vermont",
VA: "Virginia",
WA: "Washington",
WV: "West Virginia",
WI: "Wisconsin",
WY: "Wyoming",
};
export { stateOptions };

View file

@ -63,6 +63,7 @@ const storeActions = {
LOG_CUSTOM_EVENT: "logCustomEvent",
INITIALIZE_SESSION: "initializeSession",
LOG_PART_QUESTIONS: "logPartQuestions",
LOG_DIGITALCONSUMER: "logDigitalConsumer",
// DEPENDENCY MUTATIONS
RESET_VEHICLE_STATE_AND_DEPENDENCIES: "resetVehicleStateAndDependencies",
@ -78,6 +79,7 @@ const storeActions = {
SAVE_VIN_LOOKUP: "saveVinLookup",
SAVE_SERVICE_ZIP_CODE_INFO: "saveServiceZipCodeInfo",
SAVE_SERVICE_LOCATION: "saveServiceLocation",
SAVE_MOBILE_DETAILS: "saveMobileDetails",
SAVE_SCHEDULE: "saveSchedule",
SAVE_EMAIL: "saveEmail",
SAVE_PHONE_NUMBER: "savePhoneNumber",

View file

@ -38,6 +38,7 @@ const storeMutations = {
UPDATE_SERVICE_ZIP: "updateServiceZip",
UPDATE_SERVICE_LOCATION: "updateServiceLocation",
UPDATE_MOBILE_DETAILS: "updateMobileDetails",
UPDATE_SERVICE_LOCATION_TECH_NOTES: "updateServiceLocationTechNotes",
UPDATE_SCHEDULE: "updateSchedule",

View file

@ -36,7 +36,7 @@ https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Com
<div :class="getComponentLoopWrapperClasses" class="button-inner-wrapper">
<div
:class="getComponentWrapperClasses"
v-for="answer in buttonsInfo"
v-for="(answer, index) in buttonsInfo"
:key="answer.value ? answer.value : answer">
<component
:is="buttonTypeString"
@ -72,6 +72,13 @@ https://safelite.atlassian.net/wiki/spaces/DC/pages/76644418/Button+Question+Com
<slot></slot>
</div>
</transition>
<div
class="or-label"
v-if="
insertOrLabelBeforeFinalOption && buttonsInfo.length - 2 === index
">
<span>OR</span>
</div>
</div>
</div>
</fieldset>
@ -149,6 +156,11 @@ export default {
required: false,
default: false,
},
insertOrLabelBeforeFinalOption: {
type: Boolean,
required: false,
default: false,
},
},
setup(props) {
const propsClone = Object.assign({}, props);
@ -390,4 +402,16 @@ export default {
}
}
}
.or-label {
width: 100%;
text-align: center;
border-bottom: 1px solid black;
line-height: 1px;
margin: 20px 0 20px;
span {
background: #fff;
padding: 0 10px;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -481,7 +481,7 @@ describe("date-picker.vue", () => {
},
});
const spy = jest.spyOn(wrapper.vm, "setCalendarData");
wrapper.vm.$refs.timeSlotModalQuestion.initializeComponent = jest.fn();
wrapper.vm.$nextTick();
// Act

View file

@ -2,6 +2,17 @@
<div
class="date-picker text-center"
:class="`${calendarViewDirection} calendar-2025 ${showPricingByDayClass}`">
<div class="date-picker-header">
<funnelSubHeader cmsWidgetName="DatePickerSubHeaderWidget" class="mt-5" />
<textBlock
v-show="durationTextBlockCopy"
:customText="durationTextBlockCopy"
justifyText="center"
typeStyle="small"
marginTopSizeOverride="0"
class="duration-text-block" />
</div>
<fieldset id="date-picker-fieldset" ref="datePickerFieldset">
<legend class="sr-only">Select a day and time</legend>
<div
@ -140,6 +151,7 @@
dropoffCmsWidgetName="DropOffTimeSlotModal"
sameDayDropOffCmsWidgetName="SameDayDropOffTimeSlotModal"
overnightDropOffCmsWidgetName="OvernightDropOffTimeSlotModal"
dropOffOrPickATimeQuestionCmsWidgetName="DropOffOrPickATimeQuestionWidget"
:selectedDate="selectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="premiumAppointmentFee"
@ -155,6 +167,8 @@
<script>
import timeSlotQuestion from "@/layouts/schedule/time-slot-question/time-slot-question.vue";
import textBlock from "@/digital-components/text-block/text-block";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
// Supporting files
import loader from "@/ux-components/loader/loader";
@ -165,7 +179,12 @@ import {
MONTHS_OF_YEAR,
DAYS_OF_WEEK,
} from "@/digital-components/date-picker/mixins/constants";
import { PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import {
AppointmentTypeStrings,
RouteCodeFlags,
PREMIUM_FEE_PART_TYPE,
cmsWidgetFieldMappings,
} from "@/constants/schedule-constants";
import {
selectableDaysOptions,
requiredParameter,
@ -177,6 +196,7 @@ import {
import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper";
import { v4 as uuidv4 } from "uuid";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
export default {
name: "datePicker",
@ -189,6 +209,7 @@ export default {
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesInshop: [], // NOTE: this and the mobile version below use monthNum (1-based), NOT monthIndex (0-based)
selectableDatesMobile: [],
durationTextBlockCopyForInshopOrDropoff: null,
};
},
props: {
@ -288,10 +309,59 @@ export default {
this.$emit("update:modelValue", newSelectedDate);
},
},
dropOffDurationText() {
return this.getCmsContent("DropOffTimeSlotModal", cmsWidgetFieldMappings.DURATION);
},
sameDayDropoffDurationText() {
return this.getCmsContent(
"SameDayDropOffTimeSlotModal",
cmsWidgetFieldMappings.DURATION
);
},
overnightDropoffDurationText() {
return this.getCmsContent(
"OvernightDropOffTimeSlotModal",
cmsWidgetFieldMappings.DURATION
);
},
inshopDurationText() {
const inshopDurationTextWithoutTime = this.getCmsContent(
"TimeSlotModalQuestion",
cmsWidgetFieldMappings.DURATION
);
const inshopDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
return `${inshopDurationTextWithoutTime} ${inshopDurationTime}`;
},
durationTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return null;
} else if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return this.inshopDurationText;
} else if (
this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF ||
this.appointmentType === AppointmentTypeStrings.DROP_OFF
) {
return this.durationTextBlockCopyForInshopOrDropoff ?? this.inshopDurationText;
}
return null;
},
isSameDay() {
if (!this.timeSlotsForSelectedDate) {
return false;
}
const todaysDate = new Date().toISOString().split("T")[0];
return this.selectedDate === todaysDate;
},
},
methods: {
initializeComponent(initialData) {
this.setCalendarData(initialData);
this.$refs.timeSlotModalQuestion.initializeComponent();
},
fireDateSelectedEvent(event, date) {
// Ignore if arrow key selected radioButton
@ -480,11 +550,12 @@ export default {
}
const loadInitialDataPromise = new Promise((resolve, reject) => {
const response = config.getSelectableDatesCallback(
initialViewStartDate,
initialViewEndDate,
store.getters.order.serviceLocation.provider.providerNumber
);
const response = config.getSelectableDatesCallback({
startDateString: initialViewStartDate,
endDateString: initialViewEndDate,
providerNumber: config.providerNumber,
zipCode: config.zipCode,
});
resolve(response);
});
@ -874,6 +945,18 @@ export default {
handleWaitListRequested(value) {
this.$emit("waitListRequested", value);
},
getDurationTextBlockCopyForInshopOrDropoff(selectedRouteCode) {
if (selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropoffDurationText;
} else if (selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDay) {
return this.sameDayDropoffDurationText;
} else {
return this.dropOffDurationText;
}
}
return this.inshopDurationText;
},
},
watch: {
modelValue(newValue) {
@ -888,13 +971,18 @@ export default {
}
},
selectedTimeSlotInfo(newValue) {
this.$emit("TimeSlotSelected", newValue); // NEEDED ON SCHEDULE - to update footer button text and to save to Store correctly
const routeCode = newValue?.timeSlot?.routeCode;
this.durationTextBlockCopyForInshopOrDropoff =
this.getDurationTextBlockCopyForInshopOrDropoff(routeCode);
this.$emit("TimeSlotSelected", newValue); // needed to update footer button text on Schedule page and to save to Store correctly
},
},
components: {
loader,
ErrorMessage,
timeSlotQuestion,
textBlock,
funnelSubHeader,
},
};
</script>
@ -910,6 +998,10 @@ export default {
display: flex;
flex-direction: column;
.date-picker-header {
margin-bottom: 1.5rem;
}
fieldset {
flex-grow: 1;
position: relative;
@ -1354,26 +1446,9 @@ export default {
}
}
}
.inshop-version {
color: limegreen !important; // TODO - THIS IS TEMPORARY CODE; TO BE REMOVED AS PART OF CASH-845
}
.mobile-version {
color: red !important; // TODO - THIS IS TEMPORARY CODE; TO BE REMOVED AS PART OF CASH-845
}
.radio-wrapper.inshop-version {
&.selectable-day label span {
color: limegreen !important; // TODO - THIS IS TEMPORARY CODE; TO BE REMOVED AS PART OF CASH-845
}
}
.radio-wrapper.mobile-version {
&.selectable-day label span {
color: red !important; // TODO - THIS IS TEMPORARY CODE; TO BE REMOVED AS PART OF CASH-845
}
}
}
}
:deep(.button-inner-wrapper) {
:deep(.timeslots .button-inner-wrapper) {
display: flex;
max-width: 100%;
flex-wrap: wrap;

View file

@ -36,9 +36,13 @@
:id="modalId + '-modalbtn'"
ref="modalButtonMain"
loaderColor="white"
:isDisabled="footerButtonDisabled"
:buttonText="footerButtonText"
@click-event="validateAndEmit"
:class="isFooterButtonDisabled && 'form-test-invalid'" />
:class="[
isFooterButtonDisabled && 'form-test-invalid',
{ 'form-test-invalid': footerButtonDisabled === true },
]" />
</div>
</div>
</div>
@ -59,6 +63,7 @@ export default {
footerButtonText: String,
suppressPageScroll: Boolean,
staticBackdrop: Boolean,
footerButtonDisabled: Boolean,
onModalOpenedCallback: {
type: Function,
},

View file

@ -4,6 +4,10 @@
<script>
import analyticsMixin from "@/mixins/analytics-mixin";
import store from "@/store";
import { routeData } from "@/router/constants/routes";
import { deepClone } from "@/helpers/object-helper";
export default {
name: "sierraWebchat",
mixins: [analyticsMixin],
@ -24,7 +28,10 @@ export default {
},
createSierraConfig() {
return {
variables: { application: "funnel" },
variables: {
application: "funnel",
...this.getSierraContext(),
},
display: "corner",
onLoad: () => this.openSierraChatModal(),
onOpen: () => {},
@ -35,6 +42,39 @@ export default {
),
};
},
getSierraContext() {
const glassDamage = store.getters?.order?.damage?.glassToReplace;
const glassType =
glassDamage && glassDamage.length > 0
? glassDamage.length > 1
? "Multiglass"
: glassDamage[0].glassType // todo: clarify?
: "";
const damageType = store.getters.order?.damage?.isRepair ? "repair" : "replace";
const paymentType = store.getters.oreder?.payment?.isInsurance ? "insurance" : "cash";
const glassParts = store.getters.order?.lineItems?.glassParts;
const partId = glassParts && glassParts.length > 0 ? glassParts[0].partId : "";
const packageData = deepClone(store.getters?.pageData(routeData.QUOTE.name));
return {
current_page: this.pageName,
vehicle_year: store.getters.order?.vehicle?.year ?? "",
vehicle_make: store.getters.order?.vehicle?.make ?? "",
vehicle_model: store.getters.order?.vehicle?.model ?? "",
vehicle_style: store.getters.order?.vehicle?.style ?? "",
service_zip: store.getters.order?.serviceLocation?.serviceZip ?? "",
glass_type: glassType ?? "",
damage_type: damageType ?? "",
payment_type: paymentType ?? "",
quote: packageData ?? [],
part_id: partId ?? "",
recalibration: store.getters?.isRecalibrationOnOrder ?? "",
};
},
launchSierraChat() {
window.sierraConfig = this.createSierraConfig();
// Preload CSS if not already present

View file

@ -50,6 +50,7 @@
:autocomplete="autocomplete" />
<button
v-if="includeSearchIcon"
class="search-icon-button"
type="submit"
aria-label="Search button"
@click="focusSearchInput" />
@ -185,6 +186,7 @@ export default {
// Focus cursor in input when search icon is clicked
const field = document.querySelector("input");
field.focus();
this.$emit("search-icon-click");
},
async imageChanged(e) {
let file = e.target.files[0];

View file

@ -7,8 +7,10 @@
</div>
</div>
<div class="row-align d-flex px-4">
<div>
<img class="w-100 mt-1 me-4" :src="LogoImage" />
<div class="logo-block">
<img class="w-100" :src="LogoImage" />
<span>EIN: 26-3043727</span>
<!-- This will likely never change so hard coded -->
</div>
<div>
<p class="small subheadline mb-2" v-html="DonationSubHeadline"></p>
@ -79,7 +81,8 @@
</div>
<div class="row">
<div class="col-12">
<p class="mt-2 mb-4 mx-0 text-center caption" v-html="DonationFooterText"></p>
<p class="mt-2 mb-2 mx-0 text-center caption" v-html="DonationFooterText"></p>
<p class="mb-4 mx-0 text-center caption" v-html="DonationFooterText2"></p>
</div>
</div>
</form>
@ -121,6 +124,9 @@ export default {
DonationFooterText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
DonationFooterText2() {
return this.getCmsContent(this.cmsWidgetName, "FooterText2");
},
LogoImage() {
return this.getCmsContent("DonationWidget", "Image");
},
@ -195,8 +201,26 @@ export default {
}
}
img {
width: 5rem;
.logo-block {
display: flex;
flex-direction: column;
background: white;
align-items: flex-start;
justify-content: center;
padding: 0 0.5rem;
margin-right: 1rem;
height: max-content;
img {
width: 88px;
}
span {
font-size: 0.625rem;
margin: 0 auto;
padding: 0.5rem 0;
font-family: UrbanistSemibold;
}
}
.donation-slider {

View file

@ -119,7 +119,7 @@
</div>
<div v-if="donationCartItem" class="donation-amount">
<span>{{ donationCartItemName }}</span>
<span>{{ getLineItemAmount(donationCartItem.subTotal) }}</span>
<span>{{ getLineItemAmount(donationCartItem.sellingPrice) }}</span>
</div>
<div class="amount-due">
<span>{{ amountDueText }}</span>
@ -182,6 +182,7 @@ import {
getAmountDue,
getSubTotal,
getSalesTax,
getAmountDueWithDonation,
} from "@/helpers/pricing-helper.js";
// Constants
@ -211,6 +212,7 @@ export default {
isNoComp: Boolean,
isExpandedOnLoad: Boolean,
isMSRFeeApplicable: Boolean,
donationCartItem: Object,
},
data() {
return {
@ -1083,33 +1085,6 @@ export default {
donationCartItemName() {
return this.getCmsContent("DonationCartTextWidget", "Text");
},
donationCartItem() {
if (!this.lineItems || !this.lineItems.supportingItems) {
return null;
}
const donationLineItem = this.lineItems.supportingItems.find(
(item) => item.partType === partTypeStrings.DONATION
);
if (donationLineItem) {
return {
name: this.donationCartItemName,
category: cartItemCategories.SUPPORTING_ITEMS,
cartItemType: cartItemTypes.DONATION,
isDisplayed: true,
isRemovable: false,
subTotal: donationLineItem.sellingPrice,
salesTax: 0,
lineItems: [],
isCoveredByInsurance: cartItemTypesCoveredByInsurance.includes(
cartItemTypes.DONATION
),
};
}
return null;
},
removeLinkText() {
return this.getCmsContent("RemoveCartItemTextWidget", "Text");
},
@ -1156,9 +1131,15 @@ export default {
amountDue() {
if (!this.lineItems || this.lineItems.length < 1) return;
if (this.showAsPaid) return 0;
return this.isInsurance || !this.shouldHideRecalibration
? getAmountDue(this.lineItems) // calculate with recal (if on order)
: getAmountDue(this.lineItemsWithoutRecal); // calculated without recal
if (this.isInsurance || !this.shouldHideRecalibration) {
return this.donationCartItem
? getAmountDueWithDonation(this.lineItems, this.donationCartItem)
: getAmountDue(this.lineItems);
} else {
return this.donationCartItem
? getAmountDueWithDonation(this.lineItemsWithoutRecal, this.donationCartItem)
: getAmountDue(this.lineItemsWithoutRecal);
}
},
amountPaid() {
if (!this.showAsPaid) {
@ -1311,8 +1292,7 @@ export default {
.amount-due,
.amount-paid,
.donation-amount {
font-family:
UrbanistSemibold, AvertaSemibold; /* Okay to remove AvertaSemibold after 2025.06.19 merge/release */
font-family: UrbanistSemibold;
color: $black;
}
.sub-total {

View file

@ -9,6 +9,7 @@ import { GaCategories, GaActions, GaLabels } from "@/constants/analytics";
import { getSessionKeyValue } from "@/helpers/heritage-integration/cookie-helper";
import { headerKeys } from "@/constants/header-keys";
import axiosResponseInterceptorMessages from "@/constants/axios-response-interceptor-messages.js";
import { endpoints } from "@/constants/endpoints";
// Add a response interceptor for global axios error handing.
axios.interceptors.response.use(
@ -100,34 +101,38 @@ export default {
return resolve(response);
},
(error) => {
if (logApiCall) {
analyticsMixIn.methods.pushEventToGA(
GaCategories.API_RESPONSE,
GaActions.RESULT,
`${GaLabels.ERROR}_${endpoint}`,
true
);
if (endpoint.toLowerCase().includes(endpoints.LogDigitalConsumer.url)) {
return resolve({ data: null, error: "Ignore errors when logging" });
} else {
if (logApiCall) {
analyticsMixIn.methods.pushEventToGA(
GaCategories.API_RESPONSE,
GaActions.RESULT,
`${GaLabels.ERROR}_${endpoint}`,
true
);
}
if (error.response.status && error.response.status != "404") {
// Do not route to error logic when no wipers found or no promo found (404s)
const errorPayload = {
cause: `Response error ${error.response.status}`,
currentPage: pageNameToLog,
endpoint: endpoint,
};
router.bailout(errorPayload);
// do not log 404 errors from services because we return NotFound
// when a service doesn't return an object
global.$logger.logError(
`${method}: ${endpoint}: ${error.message}`,
error.response
);
}
return reject(error.response);
}
if (error.response.status && error.response.status != "404") {
// Do not route to error logic when no wipers found or no promo found (404s)
const errorPayload = {
cause: `Response error ${error.response.status}`,
currentPage: pageNameToLog,
endpoint: endpoint,
};
router.bailout(errorPayload);
// do not log 404 errors from services because we return NotFound
// when a service doesn't return an object
global.$logger.logError(
`${method}: ${endpoint}: ${error.message}`,
error.response
);
}
return reject(error.response);
}
);
});

View file

@ -339,3 +339,7 @@ export function splitCMSCopyOnParagraphTag(copy) {
// filter removes empty strings that are a result of string.split with regex
return copy.split(/(?:<p(?:.*?)>)|(?:<\/p>)/g).filter((paragraph) => paragraph !== "");
}
export function splitCMSCopyOnBR(copy) {
return copy.split("<br>");
}

View file

@ -1,6 +1,7 @@
import store from "@/store";
import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js";
import { deepClone } from "@/helpers/object-helper";
export function getDisplayAmountDue(lineItemsObject, includeTax = true) {
return getAmountDue(lineItemsObject, includeTax).toLocaleString("en-US", {
@ -62,6 +63,13 @@ export function getSalesTax(lineItemsObject) {
);
}
export function getAmountDueWithDonation(lineItemsObject, donationLineItem) {
// this is amount due with Donation added
const lineItemsCloneWithDonation = deepClone(lineItemsObject);
lineItemsCloneWithDonation.supportingItems.push(donationLineItem);
return getAmountDue(lineItemsCloneWithDonation);
}
export async function getPricingByDayPartWithPrice(pageNameToLog) {
// Get the Pricing By Day Part
const basePriceByDayPart = await baseMixin.methods.dispatchStoreActionWithLogging(

View file

@ -55,7 +55,8 @@
:damage="damageInfo"
:availableVaps="vaps"
:allowItemRemoval="false"
v-model="lineItems"
v-model="lineItemsWithoutDonation"
:donationCartItem="donationLineItem"
:showAsPaid="isPia"
servicePackageOptionsCmsName="ServicePackageTitle"
:isInsurance="isInsurance"
@ -123,6 +124,7 @@ import experimentMixin from "@/mixins/experiment-mixin.js";
import { containsRecalParts } from "@/helpers/recal-helper.js";
import donationBlock from "@/experiment-components/donation-block.vue";
import { partNumberStrings } from "@/constants/part-number-strings";
import { partTypeStrings } from "@/constants/part-type-strings";
import analyticsMixin from "@/mixins/analytics-mixin";
export default {
@ -229,7 +231,7 @@ export default {
};
const donationItems = lineItemsFromSubmittedOrder.supportingItems?.filter(
(item) => item.partNumber === partNumberStrings.DONATION
(item) => item.partType === partTypeStrings.DONATION
);
const donationAmount = donationItems?.length > 0 ? donationItems[0].sellingPrice : 0;
@ -243,7 +245,7 @@ export default {
vm.shouldDisplayFosterLove = hasFosterLoveExperiment;
vm.donationAmount = donationAmount;
vm.showDonationSuccess = lineItemsFromSubmittedOrder.supportingItems?.some(
(item) => item.partType === partNumberStrings.DONATION
(item) => item.partType === partTypeStrings.DONATION
);
vm.pushAnalytics();
@ -490,6 +492,22 @@ export default {
donationValues() {
return [1, 3, 5];
},
lineItemsWithoutDonation() {
const lineItemsClone = deepClone(this.lineItems);
lineItemsClone.supportingItems = lineItemsClone.supportingItems?.filter(
(item) => item.partType !== partTypeStrings.DONATION
);
return lineItemsClone;
},
donationLineItem() {
if (!this.lineItems) return null;
const donationLineItems = this.lineItems.supportingItems?.filter(
(item) => item.partType === partTypeStrings.DONATION
);
let output =
donationLineItems && donationLineItems.length > 0 ? donationLineItems[0] : null;
return output;
},
},
methods: {
arePagePrerequisitesValid() {

View file

@ -85,6 +85,7 @@ import { Form, defineRule } from "vee-validate";
import store from "@/store";
import { paymentMethods } from "@/constants/payment-method-constants";
import { flushSaveSessionQueue } from "@/helpers/heritage-integration/order-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
// DEFINE VALIDATION RULES
defineRule("first-name-required", required(errorMessages.FIRST_NAME_REQUIRED));
@ -157,11 +158,21 @@ export default {
getTechNotesFromStore() {
return store.getters.order.serviceLocation.techNotes;
},
getSelectedAppointmentType() {
return store.getters.order.serviceLocation.appointmentType;
},
backButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
this.pageName
);
if (this.getSelectedAppointmentType() == AppointmentTypeStrings.MOBILE) {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK_WITH_MOBILE_SERVICE,
this.pageName
);
} else {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
this.pageName
);
}
},
async forwardButtonAction() {
this.dispatchStoreAction(

View file

@ -0,0 +1,198 @@
// Components
import mobileAddressQuestions from "@/layouts/mobile-details/mobile-address-questions/mobile-address-questions.vue";
// Supporting Files
import { mount, shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import { storeMutations } from "@/constants/store-mutations";
import store from "@/store";
describe("initial state", () => {
test("Should show all the address field", () => {
// Arrange
const { wrapper } = setupMocks({});
// Assert
const streetAddressField = wrapper.findComponent({ ref: "streetAddress" });
const cityField = wrapper.findComponent({ ref: "city" });
const stateField = wrapper.findComponent({ ref: "state" });
const zipCodeField = wrapper.findComponent({ ref: "zipCode" });
expect(streetAddressField.exists()).toBe(true);
expect(streetAddressField.isVisible()).toBe(true);
expect(cityField.exists()).toBe(true);
expect(cityField.isVisible()).toBe(true);
expect(stateField.exists()).toBe(true);
expect(stateField.isVisible()).toBe(true);
expect(zipCodeField.exists()).toBe(true);
expect(zipCodeField.isVisible()).toBe(true);
});
test("Should render mobileAddressQuestions sub-components (textbox-questions and dropdown-questions)", async () => {
// Arrange
const { wrapper } = setupMocks({});
// Act
const streetAddress = wrapper.findComponent({ ref: "streetAddress" });
const city = wrapper.findComponent({ ref: "city" });
const state = wrapper.findComponent({ ref: "state" });
const zipCode = wrapper.findComponent({ ref: "zipCode" });
// Assert
expect(streetAddress.exists()).toBe(true);
expect(city.exists()).toBe(true);
expect(state.exists()).toBe(true);
expect(zipCode.exists()).toBe(true);
});
test("Should render apartmentNumberOrBusinessName textbox-question when captureApartmentNumberOrBusinessName = true", async () => {
// Arrange
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
captureApartmentNumberOrBusinessName: true,
},
});
// Act
const streetAddress = wrapper.findComponent({ ref: "streetAddress" });
const apartmentNumberOrBusinessName = wrapper.findComponent({
ref: "apartmentNumberOrBusinessName",
});
const city = wrapper.findComponent({ ref: "city" });
const state = wrapper.findComponent({ ref: "state" });
const zipCode = wrapper.findComponent({ ref: "zipCode" });
// Assert
expect(streetAddress.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.isVisible()).toBe(true);
expect(city.exists()).toBe(true);
expect(state.exists()).toBe(true);
expect(zipCode.exists()).toBe(true);
});
test("Should *not* render apartmentNumberOrBusinessName textbox-question when captureApartmentNumberOrBusinessName = false", async () => {
// Arrange
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
captureApartmentNumberOrBusinessName: false,
},
});
// Act
const streetAddress = wrapper.findComponent({ ref: "streetAddress" });
const apartmentNumberOrBusinessName = wrapper.findComponent({
ref: "apartmentNumberOrBusinessName",
});
const city = wrapper.findComponent({ ref: "city" });
const state = wrapper.findComponent({ ref: "state" });
const zipCode = wrapper.findComponent({ ref: "zipCode" });
// Assert
expect(streetAddress.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.exists()).toBe(true);
expect(apartmentNumberOrBusinessName.isVisible()).toBe(false);
expect(city.exists()).toBe(true);
expect(state.exists()).toBe(true);
expect(zipCode.exists()).toBe(true);
});
});
describe("happy paths", () => {
test("full street address is passed in => address fields are displayed", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "12345 Test Road",
city: "Tests",
state: "OH",
zipCode: "12312",
},
},
});
await wrapper.vm.$nextTick();
// Assert
const cityField = wrapper.findComponent({ ref: "city" });
const stateField = wrapper.findComponent({ ref: "state" });
const zipField = wrapper.findComponent({ ref: "zipCode" });
expect(cityField.exists()).toBeTruthy();
expect(cityField.isVisible()).toBeTruthy();
expect(stateField.exists()).toBeTruthy();
expect(cityField.isVisible()).toBeTruthy();
expect(zipField.exists()).toBeTruthy();
expect(cityField.isVisible()).toBeTruthy();
});
});
function setupMocks({
mountOptions,
props,
isShallowMount = true,
querySelectorFunction,
geocoderResult = ["1234 Test Street"],
matchFound = false,
}) {
store.commit(storeMutations.RESET_STATE);
const resultingMountOptions = getMountOptions({
...mountOptions,
router: {
navigate: jest.fn(),
navigate: jest.fn(),
},
loadScript: jest.fn().mockResolvedValue(),
});
if (props) resultingMountOptions.propsData = props;
if (isShallowMount) {
resultingMountOptions.global.stubs = {
...(resultingMountOptions.global.stubs ?? {}),
textboxQuestion: {
template: `<span></span>`,
methods: {
handleChange: jest.fn(),
},
},
dropdownQuestion: {
template: `<span></span>`,
methods: {
handleChange: jest.fn(),
},
},
};
}
const wrapper = isShallowMount
? shallowMount(mobileAddressQuestions, resultingMountOptions)
: mount(mobileAddressQuestions, resultingMountOptions);
document.querySelector = jest.fn().mockImplementation((query) => {
let result = null;
if (query == ".pac-container") result = document.createElement("div");
else if (querySelectorFunction) {
result = querySelectorFunction(query);
}
return result ?? null;
});
return { wrapper };
}

View file

@ -0,0 +1,170 @@
<template>
<div class="address-questions">
<div class="row mb-4" aria-live="polite">
<div class="col">
<!-- Temporarily changing id from autocomplete to streetAddress -->
<textboxQuestion
customInputId="streetAddress"
cmsWidgetName="StreetAddressQuestionWidget"
v-model="addressModel.streetAddress"
validationRules="street-address-required"
ref="streetAddress"
:labelBold="labelBold"
autocomplete="streetAddress" />
</div>
</div>
<transition name="fade" mode="out-in">
<div
class="row mb-4"
v-show="showApartmentNumberOrBusinessNameField"
aria-live="polite">
<div class="col">
<textboxQuestion
customInputId="apartmentNumberOrBusinessName"
cmsWidgetName="ApartmentNumberOrBusinessNameQuestionWidget"
v-model="addressModel.apartmentNumberOrBusinessName"
ref="apartmentNumberOrBusinessName"
:labelBold="labelBold" />
</div>
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row mb-4" aria-live="polite">
<div class="col">
<textboxQuestion
customInputId="city"
cmsWidgetName="CityQuestionWidget"
v-model="addressModel.city"
ref="city"
validationRules="city-required"
autocomplete="address-level2"
:labelBold="labelBold" />
</div>
</div>
</transition>
<transition name="fade" mode="out-in">
<div class="row mb-4" aria-live="polite">
<div class="col">
<dropdownQuestion
customDropdownId="state"
cmsWidgetName="StateQuestionWidget"
v-model="addressModel.state"
ref="state"
:options="stateOptions"
validationRules="state-required"
autocomplete="address-level1"
:labelBold="labelBold"
:isDisabled="this.isStateDisabled" />
</div>
<div class="col">
<textboxQuestion
customInputId="zipCode"
cmsWidgetName="ZipQuestionWidget"
v-model="addressModel.zipCode"
ref="zipCode"
mask="#####"
validationRules="zip-code-required|zip-code-format"
autocomplete="postal-code"
:labelBold="labelBold"
:isDisabled="this.isZipCodeDisabled" />
</div>
</div>
</transition>
</div>
</template>
<script>
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import dropdownQuestion from "@/digital-components/dropdown-question/dropdown-question";
import { defineRule } from "vee-validate";
import { required, regex } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import { stateOptions } from "@/constants/state-options";
// DEFINE VALIDATION RULES
defineRule("street-address-required", required(errorMessages.STREET_ADDRESS_REQUIRED));
defineRule("city-required", required(errorMessages.CITY_REQUIRED));
defineRule("state-required", required(errorMessages.STATE_REQUIRED));
defineRule("zip-code-required", required(errorMessages.ZIP_REQUIRED));
defineRule("zip-code-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.ZIP_FORMAT));
export default {
name: "mobile-address-questions",
emits: ["update:modelValue"], // The component emits an event
props: {
modelValue: {
type: Object,
default: () => ({
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
}),
},
validationRules: String,
captureApartmentNumberOrBusinessName: {
type: Boolean,
default: false,
},
preserveCityAndStateOnReset: {
type: Boolean,
default: false,
},
labelBold: {
type: Boolean,
required: false,
default: false,
},
isStateDisabled: {
type: Boolean,
required: false,
default: false,
},
isZipCodeDisabled: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
stateOptions() {
return stateOptions;
},
addressModel: {
get: function () {
return this.modelValue;
},
set: function (newValue) {
this.$emit("update:modelValue", newValue);
},
},
showApartmentNumberOrBusinessNameField: {
get: function () {
return this.captureApartmentNumberOrBusinessName;
},
},
},
components: {
textboxQuestion,
dropdownQuestion,
},
};
</script>
<style lang="scss">
.address-questions {
margin-top: 0.5rem;
}
#streetAddressField {
position: relative;
.pac-container {
top: 76px !important; // Height of #streetAddressField
left: 0 !important;
}
}
</style>

View file

@ -0,0 +1,139 @@
import mobileDetails from "@/layouts/mobile-details/mobile-details.vue";
import { mount, shallowMount } from "@vue/test-utils";
import { storeActions } from "@/constants/store-actions";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
const linkWidgetName = "linkWidgetName";
const modalWidgetName = "modalWidgetName";
const mockLinkCmsContent = {
BodyText: "Sample link body text here.",
};
const mockModalCmsContent = {
FooterText: "Sample modal footer text here.",
};
const mockMixin = {
methods: {
getCmsContent: jest.fn((widgetName, cmsFieldName) => {
if (widgetName === linkWidgetName) {
return mockLinkCmsContent[cmsFieldName];
}
if (widgetName === modalWidgetName) {
return mockModalCmsContent[cmsFieldName];
}
return null;
}),
dispatchStoreAction: jest.fn((actionName) => {
if (actionName === storeActions.SAVE_MOBILE_DETAILS) {
return true;
}
}),
},
};
beforeEach(() => {
store.getters = {
order: {
serviceLocation: {
zipCode: "43235",
zipCodeCtu: "00000",
state: "OH",
appointmentType: "Mobile",
city: "Columbus",
address: "123 Main St",
address2: "Suite 100",
isVehicleProtected: true,
},
schedule: {
date: "2023-10-10",
endTime: "14:00",
startTime: "13:00",
routeCode: "R1",
jobMaxMinutes: "60",
jobMinMinutes: "30",
},
},
payment: {
isInsurance: false,
},
};
});
describe("mobile-details.vue", () => {
test("if the continue button is clicked, navigate forward", async () => {
// Arrange
const { wrapper } = setupMocks(mobileDetails, {
mixins: [mockMixin],
attachTo: document.body,
});
wrapper.setData({
addressQuestions: {
streetAddress: "Test Street",
apartmentNumberOrBusinessName: "Test Apartment",
city: "Test City",
state: "TS",
zipCode: "12345",
},
isVehicleProtected: true,
});
// Act
await wrapper.vm.forwardButtonAction();
// Assert
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
});
test("arePagePrerequisitesValid should be true ", async () => {
//Arrange
const { wrapper } = setupMocks(mobileDetails, {
mixins: [mockMixin],
attachTo: document.body,
});
//Act
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
//Assert
expect(arePagePrerequisitesValid).toBe(true);
});
test("if the back button is clicked, navigate back", async () => {
// Arrange
const { wrapper } = setupMocks(mobileDetails, {
mixins: [mockMixin],
attachTo: document.body,
});
// Act
await wrapper.vm.backButtonAction();
// Assert
expect(wrapper.vm.$router.navigateWithoutSaving).toHaveBeenCalled();
});
});
function setupMocks({ mountOptions, mixins, props, isShallowMount = true }) {
const resultingMountOptions = getMountOptions({
...mountOptions,
mixins,
router: {
navigate: jest.fn(),
navigateWithSaving: jest.fn(),
navigateWithoutSaving: jest.fn(),
},
});
if (props) resultingMountOptions.propsData = props;
resultingMountOptions["attachTo"] = document.body;
const wrapper = isShallowMount
? shallowMount(mobileDetails, resultingMountOptions)
: mount(mobileDetails, resultingMountOptions);
return { wrapper };
}

View file

@ -0,0 +1,168 @@
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4 mt-4">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<div class="mobile-location-questions">
<div class="address-questions-container">
<mobileAddressQuestions
ref="addressQuestions"
v-model="this.addressQuestions"
captureApartmentNumberOrBusinessName="true"
preserveCityAndStateOnReset="true"
labelBold="true"
isStateDisabled="true"
isZipCodeDisabled="true" />
<vehicleProtectedQuestion
ref="vehicleProtectedQuestion"
v-model="this.isVehicleProtected"
cmsWidgetName="VehicleProtectedQuestionWidget"
labelBold="true" />
<textBlock
cmsWidgetName="WorkspaceRequirementsWidget"
typeStyle="caption" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</div>
</div>
</Form>
</template>
<script>
//Components
import textBlock from "@/digital-components/text-block/text-block";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form } from "vee-validate";
import mobileAddressQuestions from "@/layouts/mobile-details/mobile-address-questions/mobile-address-questions";
import vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question";
import store from "@/store";
//Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
export default {
name: "MobileDetails",
data() {
return {
addressQuestions: {
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(),
city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
},
isVehicleProtected: this.getIsVehicleProtectedFromStore(),
};
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.name);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
});
},
methods: {
arePagePrerequisitesValid() {
const serviceLocation = store.getters.order.serviceLocation;
const serviceLocationPreReqs =
serviceLocation.zipCode &&
serviceLocation.zipCodeCtu &&
serviceLocation.appointmentType &&
serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE;
// Schedule
const schedule = store.getters.order.schedule;
const scheduleReqs = !!(
schedule.date &&
schedule.startTime &&
schedule.endTime &&
schedule.jobMaxMinutes &&
schedule.jobMinMinutes
);
const preReqResult = serviceLocationPreReqs && scheduleReqs;
return preReqResult;
},
getServiceAddressFromStore() {
return store.getters.order.serviceLocation.address;
},
getServiceAddress2FromStore() {
return store.getters.order.serviceLocation.address2;
},
getServiceCityFromStore() {
return store.getters.order.serviceLocation.city;
},
getServiceStateFromStore() {
return store.getters.order.serviceLocation.state;
},
getServiceZipCodeFromStore() {
return store.getters.order.serviceLocation.zipCode;
},
getIsVehicleProtectedFromStore() {
return store.getters.order.serviceLocation.isVehicleProtected;
},
async forwardButtonAction() {
await this.dispatchStoreAction(
this.storeActions.SAVE_MOBILE_DETAILS,
{
address: this.addressQuestions.streetAddress,
address2: this.addressQuestions.apartmentNumberOrBusinessName,
city: this.addressQuestions.city,
isVehicleProtected: this.isVehicleProtected,
},
false
);
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
);
},
backButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
this.pageName
);
},
},
computed: {
headerText() {
return this.getCmsContent("MobileLocationModalWidget", "HeaderText");
},
},
components: {
mobileAddressQuestions,
vehicleProtectedQuestion,
textBlock,
funnelHeader,
navbar,
funnelSubHeader,
Form,
loadingModal,
},
};
</script>

View file

@ -82,6 +82,8 @@
<span>Amount Due</span>
</div>
</div>
<p>6 or 12 monthly payment plans available</p>
</div>
</div>
</div>
@ -208,6 +210,12 @@ export default {
overflow: hidden;
opacity: 0;
visibility: hidden;
> p {
padding-top: 1rem;
font-size: $font-size-12;
margin: 0;
}
}
}
.payment-section {

View file

@ -1,13 +1,14 @@
<template>
<div class="alert fade show my-2 py-2 border-0 alert-info" role="alert">
<div id="afterpay-banner" role="alert">
<component
:is="'script'"
src="https://js.squarecdn.com/square-marketplace.js"
async></component>
<div
class="mx-4 my-0 alert-heading text-center"
:class="[isAfterpayBreakoutDisplay ? 'fw-bolder' : 'fw-bold']">
<div>
<span v-for="token in headerCopyTokens" :key="token" v-html="token"></span>
</div>
<div :id="showAfterpayExtendedPayOption ? 'extended-pay-option' : ''">
<span v-for="token in afterpayCopyTokens" :key="token">
<span v-if="isAfterpayPriceToken(token)">{{ afterpayPrice }}</span>
<span v-else-if="isInlineImageToken(token)">
@ -29,7 +30,7 @@
</template>
<script>
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import { splitCopyOnCMSPlaceHolder, splitCMSCopyOnBR } from "@/helpers/cms-content-helper";
import baseMixin from "@/mixins/base-mixin.js";
import { getPromosThatMatchLineItemsOnOrder } from "@/helpers/promotions-helper";
import { getArrayOfAllLineItemsAndChildParts } from "@/store";
@ -55,8 +56,8 @@ export default {
props: {
cmsWidgetName: String,
lineItems: Array,
isAfterpayBreakoutDisplay: Boolean,
isInsuranceSelected: Boolean,
afterpayExtendedPayOptionThreshold: Number,
},
data() {
return {};
@ -73,27 +74,24 @@ export default {
hasImage() {
return !!this.imageUrl;
},
headerCopyTokens() {
return splitCMSCopyOnBR(this.getCmsContent(this.cmsWidgetName, "HeaderText"));
},
afterpayCopyTokens() {
if (this.isAfterpayBreakoutDisplay) {
// Text for Afterpay Breakout experiment
var afterpayBannerCopy = this.getCmsContent(this.cmsWidgetName, "BodyText2");
afterpayBannerCopy =
afterpayBannerCopy &&
afterpayBannerCopy.replaceAll(
"{custom:paymentType}",
this.isInsuranceSelected ? "deductible" : "spending"
);
return splitCopyOnCMSPlaceHolder(afterpayBannerCopy);
if (this.showAfterpayExtendedPayOption) {
return splitCopyOnCMSPlaceHolder(
this.getCmsContent(this.cmsWidgetName, "BodyText")
);
} else {
return splitCopyOnCMSPlaceHolder(
this.getCmsContent(this.cmsWidgetName, "HeaderText")
this.getCmsContent(this.cmsWidgetName, "BodyText2")
);
}
},
modalCopy() {
return this.getCmsContent(this.cmsWidgetName, "BodyText");
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
afterpayPrice() {
getTierOnePackagePrice() {
let allLineItems = getArrayOfAllLineItemsAndChildParts(this.lineItems);
if (this.lineItems.promos) {
allLineItems = allLineItems?.filter((item) => item.partType !== "PROMO_DISCOUNT");
@ -103,11 +101,6 @@ export default {
baseMixin.methods.filterOutFees(allLineItems)
);
let vapsLineItemsForSelectedPackage = this.lineItems.vaps;
vapsLineItemsForSelectedPackage.forEach((lineItem) => {
price += baseMixin.methods.getTotalLineItemPrice(lineItem);
});
if (this.lineItems.promos) {
let allLineItems = getArrayOfAllLineItemsAndChildParts(this.lineItems);
const promos = getPromosThatMatchLineItemsOnOrder(
@ -119,9 +112,13 @@ export default {
});
}
const adjusted = (price / 4).toFixed(2);
return `$${adjusted}`;
return price;
},
showAfterpayExtendedPayOption() {
return (
this.afterpayExtendedPayOptionThreshold &&
this.getTierOnePackagePrice >= this.afterpayExtendedPayOptionThreshold
);
},
},
components: {},
@ -129,28 +126,54 @@ export default {
</script>
<style lang="scss" scoped>
.alert {
padding: 0.675rem 0;
&.alert-info {
background-color: $blue-100;
.alert-heading {
color: $blue-700;
#afterpay-banner {
display: flex;
flex-direction: column;
background-color: $blue-150;
justify-content: center;
align-items: center;
padding: 1rem;
border-radius: 1rem;
@include media-breakpoint-up(md) {
flex-direction: row;
}
> div:first-of-type {
display: flex;
color: $black;
font-size: $font-size-20;
font-weight: $font-weight-600;
line-height: 2rem;
@include media-breakpoint-down(md) {
border-bottom: 1px solid;
padding-bottom: 1rem;
width: 100%;
justify-content: center;
> span:first-of-type {
margin-right: 0.25rem;
}
}
&.fw-bolder {
font-weight: 600;
}
img {
vertical-align: text-top;
}
svg {
fill: $blue-700;
width: 1rem;
height: 1rem;
@include media-breakpoint-up(md) {
flex-direction: column;
border-right: 1px solid;
padding-right: 1rem;
}
}
& .alert-heading {
font-size: 0.875rem;
> div:last-of-type {
color: $gray-650;
text-align: left;
@include media-breakpoint-down(md) {
padding-top: 1rem;
}
@include media-breakpoint-up(md) {
padding-left: 1rem;
max-width: 26rem;
}
}
#afterpay-learnmore {
white-space: nowrap;
}

View file

@ -46,7 +46,7 @@
<afterpayModalBanner
v-if="showAfterpayBanner"
cmsWidgetName="AfterpayModalWidget"
:isAfterpayBreakoutDisplay="isAfterpayBreakoutDisplay"
:afterpayExtendedPayOptionThreshold="afterpayExtendedPayOptionThreshold"
:isInsuranceSelected="isInsuranceSelected"
:lineItems="lineItems" />
@ -561,8 +561,12 @@ export default {
skipToInsurance: null,
};
},
mounted() {
async mounted() {
this.attachCustomEventsForAnalytics();
await this.$nextTick();
this.$refs?.servicePackage?.savePackageInfoToPageData?.();
},
computed: {
lineItemsCloneForWatcher() {
@ -591,10 +595,7 @@ export default {
);
},
showAfterpayBanner() {
return (
(this.isAfterpayBreakoutDisplay || !this.isInsuranceSelected) &&
(!this.isRecalibrationOnOrder || !this.shouldHideRecalibration)
);
return !this.isRecalibrationOnOrder || !this.shouldHideRecalibration;
},
isRecalPriceRemove() {
return (
@ -606,14 +607,12 @@ export default {
showRecalDisclaimer() {
return this.isRecalibrationOnOrder && this.isRecalPriceRemove;
},
isAfterpayBreakoutDisplay() {
return (
(!this.isRecalPriceRemove || !this.isRecalibrationOnOrder) &&
experimentMixin.methods.hasSettingEqualTo(
experimentSettings.DISPLAY_AFTERPAY_BREAKOUT_DISPLAY,
"true"
)
afterpayExtendedPayOptionThreshold() {
let thresholdAmount = experimentMixin.methods.getSettingValue(
experimentSettings.AFTERPAY_EXTENDED_PAY_OPTION_THRESHOLD
);
thresholdAmount && (thresholdAmount = parseInt(thresholdAmount));
return thresholdAmount;
},
},
methods: {

View file

@ -44,6 +44,7 @@ import {
import { containsRecalParts, getItemsWithoutRecalParts } from "@/helpers/recal-helper";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { savePageData } from "@/router/methods/helpers/save-page-data";
export default {
name: "servicePackageQuestion",
@ -434,6 +435,15 @@ export default {
return null;
}
},
async savePackageInfoToPageData() {
const answers = this.servicePackageAnswers;
const toLog = answers.map((ans) => ({
packageName: ans.buttonLabel,
price: ans.buttonAuxillaryCopy,
}));
await savePageData(this.pageName, { packages: toLog });
},
},
components: {
buttonQuestion,

View file

@ -1,5 +1,6 @@
import { storeActions } from "@/constants/store-actions";
import baseMixin from "@/mixins/base-mixin.js";
import { RouteCodeFlags } from "@/constants/schedule-constants";
export async function getAlertReasons(ctu) {
const alertReasons = await baseMixin.methods.dispatchStoreActionWithLogging(
@ -60,3 +61,13 @@ export function militaryToTwelveHourTime(timeString) {
return `${hours}:${minutes} ${meridianNotation}`;
}
export function isDropOffRouteCode(routeCode) {
if (!routeCode) {
return false;
}
return (
routeCode.includes(RouteCodeFlags.ALL_DAY_DROP_OFF) ||
routeCode.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)
);
}

View file

@ -0,0 +1,782 @@
<!-- OLD SCHEDULE BEGINS -->
<!-- TODO: REMOVE THIS FILE ONCE CASH-803 (SERVICE-LOCATION AND SCHEDULE PAGE COMBINATION) HAS BEEN VETTED -->
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles page-schedule">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" class="mt-4" />
<template v-if="ChangeShopLink.length">
<textBlock
cmsWidgetName="ChangeShopLink"
justifyText="center"
class="mb-5 text-link-small change-location"
marginTopSizeOverride="1" />
</template>
<locationAlerts cmsWidgetPrefix="LocationAlert-" ref="locationAlerts" />
<datePicker
customComponentId="dateQuestion"
selectableDatesSetting="custom"
ref="datePicker"
v-model="selectedDate"
class="text-link-small"
:customSelectableDatesCallback="getAvailableDatesMethod"
validationRules="date-required"
@date-clicked="handleDateClicked"
:pricingByDayBasePrice="pricingByDayBasePrice"
:pricingByDayUpcharge="pricingByDayUpcharge"
:showPricingByDay="showPricingByDay"
:isPricingByDayExperiment="isPricingByDayExperiment"
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee"
:estimatedServiceMinutesMinimum="
selectableDatesData.estimatedServiceMinutesMinimum
"
:estimatedServiceMinutesMaximum="
selectableDatesData.estimatedServiceMinutesMaximum
"
@TimeSlotSelected="updateTimeSlot"
:displayWaitList="displayWaitList"
@waitListRequested="handleWaitListRequested" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</Form>
</template>
<script>
// Components
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
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 locationAlerts from "@/layouts/schedule/location-alerts/location-alerts";
import textBlock from "@/digital-components/text-block/text-block";
// Supporting files
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import baseMixin from "@/mixins/base-mixin.js";
import { storeActions } from "@/constants/store-actions";
import { settleAllPromises } from "@/helpers/layout-helper";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import {
calcDaysBetweenDates,
convertDateStringToDate,
sumDateString,
} from "@/layouts/schedule/helpers/schedule-helper";
import {
AppointmentTypeStrings,
PREMIUM_FEE_PART_TYPE,
PRICING_BY_DAY_PART_TYPE,
} from "@/constants/schedule-constants";
import { DAYS_OF_WEEK } from "@/digital-components/date-picker/mixins/constants";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
import store from "@/store";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { getAmountDue, getPricingByDayPartWithPrice } from "@/helpers/pricing-helper.js";
import { getItemsWithoutRecalParts } from "@/helpers/recal-helper";
import { partNumberStrings } from "@/constants/part-number-strings";
import { deepClone } from "@/helpers/object-helper";
import { debugLog } from "@/helpers/debug-log-helper";
// DEFINE VALIDATION RULES
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
const getAvailableDates = async (
startDateString,
endDateString,
appointmentType,
providerNumber
) => {
const apiEndDateLimit = sumDateString(startDateString, TIME_SLOTS_CALL_DAYS_LIMIT);
const difference = calcDaysBetweenDates(startDateString, endDateString);
const apiCallsCount = Math.ceil(difference / TIME_SLOTS_CALL_DAYS_LIMIT);
const storeActionConfigs = [];
const timeSlotsData = {};
timeSlotsData.days = [];
let apiStartDate = startDateString;
let apiEndDate = endDateString;
for (let i = 1; i <= apiCallsCount; i++) {
let storeActionConfig;
if (i > 1) {
apiStartDate = sumDateString(apiEndDate, 1);
apiEndDate = sumDateString(apiStartDate, TIME_SLOTS_CALL_DAYS_LIMIT);
if (i === apiCallsCount) {
apiEndDate = endDateString;
}
} else {
if (apiEndDate > apiEndDateLimit) {
apiEndDate = apiEndDateLimit;
}
}
if (appointmentType === AppointmentTypeStrings.MOBILE) {
storeActionConfig = {
storeAction: storeActions.GET_MOBILE_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
},
};
} else {
storeActionConfig = {
storeAction: storeActions.GET_SHOP_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
shopAppointmentType: appointmentType,
providerNumber: providerNumber,
},
};
}
if (apiStartDate < apiEndDate) storeActionConfigs.push(storeActionConfig);
}
const timeSlotsResponsesData = {
days: [],
};
function compareDayStrings(a, b) {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
}
const makeParallelCalls = async () => {
await Promise.all(
storeActionConfigs.map(async (storeAction) => {
const timeSlotsResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeAction.storeAction,
storeAction.payload,
"schedule",
false
);
timeSlotsResponsesData.estimatedServiceMinutesMinimum =
timeSlotsResponse.data.estimatedServiceMinutesMinimum;
timeSlotsResponsesData.estimatedServiceMinutesMaximum =
timeSlotsResponse.data.estimatedServiceMinutesMaximum;
timeSlotsResponsesData.days = [
...timeSlotsResponsesData.days,
...timeSlotsResponse.data.days,
];
})
);
};
return makeParallelCalls().then(() => {
// sort days chronologically
timeSlotsResponsesData.days.sort(compareDayStrings);
return timeSlotsResponsesData;
});
};
export default {
name: "schedule",
data() {
return {
selectedDate: this.getSelectedDate(),
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesData: [],
mobilePremiumAppointmentFee: null,
waitListRequested: null,
displayWaitList: null,
pricingByDayUpchargeLineItem: null,
includePricingByDayUpcharge: null,
isPricingByDayExperiment: null,
pricingByDayBasePrice: null,
pricingByDayUpcharge: null,
showPricingByDay: null,
};
},
async beforeRouteEnter(to, from, next) {
const isPricingByDayExperiment = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.PRICING_BY_DAY,
"true"
);
const showPricingByDay = !store.getters.payment.isInsurance && isPricingByDayExperiment;
// Get pricingByDayBasePrice needed for Pricing By Day
const lineItems = deepClone(store.getters.order.lineItems);
const isRecalibrationOnOrder = store.getters.isRecalibrationOnOrder;
const shouldHideRecalibration =
experimentMixin.methods.hasSettingEqualTo(
experimentSettings.RECAL_PRICE_REMOVE,
"true"
) && isRecalibrationOnOrder;
const glassParts =
isRecalibrationOnOrder && shouldHideRecalibration
? getItemsWithoutRecalParts(lineItems.glassParts)
: (lineItems.glassParts ?? []);
const supportingItemsFromStore = lineItems?.supportingItems;
const supportingItemsWithoutFees = baseMixin.methods.filterOutCertainPartTypesOrNumbers(
lineItems.supportingItems,
{
partNumbersToRemove: [
partNumberStrings.RECYCLE_FEE,
partNumberStrings.PRICING_BY_DAY_UPCHARGE,
],
}
);
const lineItemsToBePriced = {
glassParts: glassParts,
supportingItems: supportingItemsWithoutFees,
vaps: lineItems.vaps ?? [],
promos: lineItems.promos ?? [],
};
const priceString = getAmountDue(lineItemsToBePriced, false); // pass the IncludeTax param as false
const priceStringIntegerRoundedDown = priceString?.split(".")[0]; // same method used as getDisplayPrice() in service-package-radio used on /quote
const pricingByDayBasePrice = parseInt(priceStringIntegerRoundedDown);
let includePricingByDayUpcharge = false;
// Check to see if date should be pre-selected
let preSelectedSlot = await store.getters.order.schedule;
if (!preSelectedSlot.date || preSelectedSlot?.date?.length < 1) {
preSelectedSlot = null;
} else {
// Check to see if pre-selected date should have pricing by day upcharge
if (showPricingByDay) {
// is this preSelectedDate a higher priced pricingByDay day?
const dayIndex = convertDateStringToDate(preSelectedSlot?.date).getDay();
const dayObject = DAYS_OF_WEEK[dayIndex];
if (dayObject.isPricingByDayUpchargeDay) {
includePricingByDayUpcharge = true;
}
}
}
// Set up promises
const cmsContentPromise = fetchCmsContentForPage(to.name);
const alertReasonsPromise = locationAlerts.methods.loadInitialData(
store.getters.order.serviceLocation.zipCodeCtu,
store.getters.order.serviceLocation.provider?.address?.zipCodeCtu
);
// While Pricing By Day Experiment is active, using the updated datePicker
const datePickerInitialDataPromise = await datePicker.methods.loadInitialData({
// setup config options for date-picker
selectableDatesSetting: "custom",
initialViewRowsToShow: 2,
customSelectableDatesCallback: getAvailableDates,
preSelectedDate: preSelectedSlot ? preSelectedSlot.date : preSelectedSlot,
});
// Get pricingByDayUpcharge needed for Pricing By Day
const pricingByDayUpchargePartPromise = showPricingByDay
? getPricingByDayPartWithPrice()
: null;
const premiumFeePromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_MOBILE_PREMIUM_FEE,
null,
"schedule"
);
const premiumFeeWithPricePromise = premiumFeePromise.then((result) => {
if (result.data) {
return baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: [result.data],
},
"schedule",
false
);
} else {
return result.data;
}
});
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "alertReasons",
promise: alertReasonsPromise,
},
{
resultKey: "datePickerInitialData",
promise: datePickerInitialDataPromise,
},
{
resultKey: "pricingByDayUpchargePart",
promise: pricingByDayUpchargePartPromise,
},
{
resultKey: "premiumFeeWithPrice",
promise: premiumFeeWithPricePromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
const pricingByDayUpcharge = showPricingByDay
? await baseMixin.methods.getTotalLineItemPrice(
resultMap.pricingByDayUpchargePart,
false
)
: null;
const datePickerInitialData = resultMap.datePickerInitialData;
datePickerInitialData.pricingByDayBasePrice = pricingByDayBasePrice;
datePickerInitialData.pricingByDayUpcharge = pricingByDayUpcharge;
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.$refs.datePicker.initializeComponent(datePickerInitialData);
vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons);
vm.selectableDatesData = datePickerInitialData.initialShopTimeSlotsResponse;
vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice
? resultMap.premiumFeeWithPrice[0]
: null;
vm.updateFooterButtonText(vm.selectedTimeSlotInfo);
vm.setDisplayWaitList();
vm.pricingByDayUpchargeLineItem = resultMap.pricingByDayUpchargePart;
vm.includePricingByDayUpcharge = includePricingByDayUpcharge;
vm.isPricingByDayExperiment = isPricingByDayExperiment;
vm.pricingByDayBasePrice = pricingByDayBasePrice;
vm.pricingByDayUpcharge = pricingByDayUpcharge;
vm.showPricingByDay = showPricingByDay;
});
},
mounted() {
this.$nextTick(() => {
const selectableDatesData = this.selectableDatesData;
// if no date selected on load
if (!this.selectedDate) {
if (selectableDatesData?.days?.length > 0) {
this.selectedDate = selectableDatesData.days[0].date;
} else {
setTimeout(() => {
this.$refs.datePicker.showAnotherMonth().then((moreSelectableDatesData) => {
this.selectableDatesData = moreSelectableDatesData;
if (selectableDatesData?.days?.length > 0) {
this.selectedDate = selectableDatesData.days[0].date;
}
this.setDisplayWaitList();
});
}, 50);
}
}
});
},
computed: {
ChangeShopLinkText() {
return this.getCmsContent("ChangeShopLink", "Text");
},
ChangeShopLink() {
// 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);
},
appointmentType() {
return this.$store.getters.order.serviceLocation.appointmentType;
},
timeSlotsForSelectedDate() {
if (!this.selectedDate) return null;
return this.selectableDatesData.days?.find(
(selectableDate) => selectableDate.date === this.selectedDate
);
},
},
methods: {
splitCopyOnCMSPlaceHolder,
arePagePrerequisitesValid() {
const serviceLocation = store.getters.order.serviceLocation;
const serviceLocationPreReqs =
serviceLocation.zipCode &&
serviceLocation.zipCodeCtu &&
serviceLocation.appointmentType &&
(serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE ||
serviceLocation.provider.providerNumber);
const paymentInfo = store.getters.payment.isInsurance !== null;
const damageInfo =
store.getters.order.damage.isRepair ||
(store.getters.order.lineItems?.glassParts != null &&
store.getters.order.lineItems.glassParts.length > 0);
const preReqResult = serviceLocationPreReqs && paymentInfo && damageInfo;
// prettier-ignore
{
debugLog("--- schedule.vue pagePrereqs start ---", null, !preReqResult);
debugLog("store.getters.order.serviceLocation.zipCode:", serviceLocation.zipCode, !preReqResult);
debugLog("store.getters.order.serviceLocation.zipCodeCtu:", serviceLocation.zipCodeCtu, !preReqResult);
debugLog("store.getters.order.serviceLocation.appointmentType:", serviceLocation.appointmentType, !preReqResult);
debugLog("store.getters.order.serviceLocation.provider.providerNumber:", serviceLocation.provider?.providerNumber, !preReqResult);
debugLog("store.getters.payment.isInsurance:", store.getters.payment?.isInsurance, !preReqResult);
debugLog("store.getters.order.damage.isRepair:", store.getters.order.damage?.isRepair, !preReqResult);
debugLog("store.getters.order.lineItems.glassParts:", store.getters.order.lineItems?.glassParts, !preReqResult);
debugLog("--- schedule.vue pagePrereqs end ---", null, !preReqResult);
}
return preReqResult;
},
async getAvailableDatesMethod(startDate, endDate) {
const newShopTimeSlots = await getAvailableDates(
startDate,
endDate,
this.appointmentType,
this.$store.getters.order.serviceLocation.provider.providerNumber
);
// ADD API CALL RESULTS TO EXISTING DATE DATA
this.selectableDatesData.days = this.selectableDatesData.days.concat(
newShopTimeSlots.days
);
return newShopTimeSlots;
},
getAvailableDates,
getServiceZipCtuCodeFromStore() {
return store.getters.order.serviceLocation.zipCodeCtu;
},
getSelectedDate() {
return store.getters.order.schedule.date;
},
getSelectedTimeSlotInfo() {
const supportingItems = this.getSupportingItems();
var isPremiumAppointment = false;
if (supportingItems) {
isPremiumAppointment =
!!supportingItems.filter(
(lineItem) => lineItem.partType === PREMIUM_FEE_PART_TYPE
).length > 0;
}
const selectedTimeSlotInfo = {
timeSlot: store.getters.order.schedule,
isPremiumAppointment: isPremiumAppointment,
};
return selectedTimeSlotInfo;
},
getSupportingItems() {
return store.getters.lineItems.supportingItems;
},
updateFooterButtonText(timeSlotInfo) {
let navbarButtonText;
if (!timeSlotInfo || !timeSlotInfo.timeSlot.date) {
navbarButtonText = "Continue";
} else {
navbarButtonText = `Select ${this.convertSelectedDateToShortMonthAndDay(
timeSlotInfo.timeSlot.date
)}`;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime
)}`;
} else if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
!timeSlotInfo.isPremiumAppointment
) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime,
true
)} - ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.endTime,
true
)}`;
}
}
this.$refs.navbar.updateButtonText(navbarButtonText);
},
convertSelectedDateToShortMonthAndDay(selectedDate) {
// This conversion ensures we don't get get GMT induced date changes
const dateObject = convertDateStringToDate(selectedDate);
// Ex: April 25
return dateObject.toLocaleDateString("en-us", { month: "short", day: "numeric" });
},
getDisplayTextForMilitaryTime(militaryTimeInput, shouldTrimMinutesIfEmpty = false) {
// Expected input: "HH:MM"
let hours = parseInt(militaryTimeInput.split(":")[0]);
const minutes = militaryTimeInput.split(":")[1];
const meridianNotation = hours > 11 ? "PM" : "AM";
if (hours > 12) {
hours -= 12;
}
if (shouldTrimMinutesIfEmpty && minutes === "00") {
return `${hours} ${meridianNotation}`;
} else {
return `${hours}:${minutes} ${meridianNotation}`;
}
},
backButtonAction() {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
this.pageName
);
},
forwardButtonAction() {
this.updateSupportingItems();
if (this.displayWaitList) {
var gaLabel = "";
const status = this.waitListRequested ? "checked" : "unchecked";
const type = store.getters.isMobileAppointment ? "mobile" : "inshop";
gaLabel = `${status}_${type}`;
const currentDate = new Date();
const dateString = this.selectableDatesData.days[0].date;
const [year, month, day] = dateString.split("-").map(Number);
const appointmentDate = new Date(year, month - 1, day);
const timeDifference = appointmentDate - currentDate;
// Convert the time difference from milliseconds to days
const daysUntilAppointment = Math.ceil(timeDifference / (1000 * 60 * 60 * 24));
gaLabel +=
"_" +
daysUntilAppointment.toString() +
"_" +
store.getters.order.serviceLocation.zipCode +
"_" +
store.getters.order.serviceLocation.zipCodeCtu;
this.pushEventToGA("waitlist", "add_to_waitlist_check_box_status", gaLabel, true);
}
if (this.selectedTimeSlotInfo.timeSlot.jobMinMinutes == null) {
this.selectedTimeSlotInfo.timeSlot.jobMinMinutes =
this.selectableDatesData?.estimatedServiceMinutesMinimum?.toString();
this.selectedTimeSlotInfo.timeSlot.jobMaxMinutes =
this.selectableDatesData?.estimatedServiceMinutesMaximum?.toString();
}
this.dispatchStoreAction(
this.storeActions.SAVE_SCHEDULE,
this.selectedTimeSlotInfo.timeSlot,
false
);
if (this.waitListRequested !== null && this.waitListRequested !== undefined) {
this.dispatchStoreAction(
this.storeActions.SAVE_WAITLIST_REQUESTED,
this.waitListRequested,
false
);
}
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
);
},
setDisplayWaitList() {
if (
experimentMixin.methods.hasSettingEqualTo(
experimentSettings.DISPLAY_WAITLIST,
"true"
) &&
this.selectableDatesData.days[0]
) {
const dateString = this.selectableDatesData.days[0].date;
const [year, month, day] = dateString.split("-").map(Number);
const targetDate = new Date(year, month - 1, day);
const currentDate = new Date();
const futureDate = new Date(currentDate);
const experimentThresholdDays = experimentMixin.methods.hasSetting(
experimentSettings.WAITLIST_THRESHOLD_DAYS
)
? parseInt(
experimentMixin.methods.getSettingValue(
experimentSettings.WAITLIST_THRESHOLD_DAYS
)
)
: 0;
futureDate.setDate(currentDate.getDate() + experimentThresholdDays);
if (targetDate >= futureDate) {
this.displayWaitList = true;
} else {
this.displayWaitList = false;
}
}
},
updateSupportingItems() {
const supportingItems = this.getSupportingItems();
// if we have a pricing by day upcharge, then save/update supporting items with it
const pricingByDayUpchargeFeeIndex = supportingItems?.findIndex(
(item) => item.partType == PRICING_BY_DAY_PART_TYPE
);
if (this.includePricingByDayUpcharge && this.showPricingByDay) {
if (pricingByDayUpchargeFeeIndex && pricingByDayUpchargeFeeIndex > -1) {
supportingItems[pricingByDayUpchargeFeeIndex].laborAmount =
this.pricingByDayUpchargeLineItem.laborAmount;
supportingItems[pricingByDayUpchargeFeeIndex].sellingPrice =
this.pricingByDayUpchargeLineItem.sellingPrice;
supportingItems[pricingByDayUpchargeFeeIndex].kitPrice =
this.pricingByDayUpchargeLineItem.kitPrice;
} else {
supportingItems.push(this.pricingByDayUpchargeLineItem);
}
} else {
if (pricingByDayUpchargeFeeIndex >= 0) {
// remove pricing by day upcharge if it already was in store
supportingItems.splice(pricingByDayUpchargeFeeIndex, 1);
}
}
// if we have a premium fee(early bird), then save/update supporting items
if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
this.selectedTimeSlotInfo?.isPremiumAppointment
) {
const premiumFeeIndex = supportingItems.findIndex(
(item) => item.partType == PREMIUM_FEE_PART_TYPE
);
if (premiumFeeIndex > -1) {
supportingItems[premiumFeeIndex].laborAmount =
this.mobilePremiumAppointmentFee.laborAmount;
supportingItems[premiumFeeIndex].sellingPrice =
this.mobilePremiumAppointmentFee.sellingPrice;
supportingItems[premiumFeeIndex].kitPrice =
this.mobilePremiumAppointmentFee.kitPrice;
} else {
supportingItems.push(this.mobilePremiumAppointmentFee);
}
} else {
if (!supportingItems) {
return;
}
// if it's not a mobile and/or premium early bird, then make sure we remove any that may have been added
const removePremiumFeeIndex = supportingItems.findIndex(
(item) => item.partType == PREMIUM_FEE_PART_TYPE
);
if (removePremiumFeeIndex >= 0) {
supportingItems.splice(removePremiumFeeIndex, 1);
}
}
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
supportingItems,
false
);
},
handleWaitListRequested(value) {
this.waitListRequested = value;
},
handleDateClicked(date) {
// do something to mark this as upcharge day or not...
if (date.isPricingByDayUpchargeDay) {
this.includePricingByDayUpcharge = true;
} else {
this.includePricingByDayUpcharge = false;
}
},
updateTimeSlot(timeSlot) {
this.selectedTimeSlotInfo = timeSlot;
},
},
watch: {
selectedDate(newValue, oldValue) {
// Clear time slot selection if date selected changes
if (newValue !== oldValue) {
this.selectedTimeSlotInfo = {
timeSlot: {
date: null,
routeCode: null,
startTime: null,
endTime: null,
jobMaxMinutes: null,
jobMinMinutes: null,
},
isPremiumAppointment: null,
};
}
},
selectedTimeSlotInfo(newValue) {
this.updateFooterButtonText(newValue);
},
},
components: {
funnelHeader,
navbar,
funnelSubHeader,
Form,
loadingModal,
datePicker,
locationAlerts,
textBlock,
},
};
</script>
<style lang="scss">
.container-fluid {
&.page-schedule {
padding: 0 1rem;
.text-link-small {
a,
.btn-link {
width: auto;
margin: 0 auto;
height: auto;
font-size: 0.875rem;
line-height: 1.75;
padding: 0;
border-radius: 0;
&:focus {
outline: 1px solid $blue;
}
@include media-breakpoint-up(md) {
font-size: 1rem;
}
}
}
.funnel-sub-header {
h5.dark-header {
margin-bottom: 0.25rem;
}
}
.change-location a {
font-family: AvertaSemibold;
}
.time-slots-question {
padding: 0 0.75rem;
}
}
}
</style>
<!-- OLD SCHEDULE ENDS -->

View file

@ -176,6 +176,9 @@ beforeEach(() => {
isRepair: false,
},
referralNumber: "1234567",
policy: {
policyNumber: "123",
},
},
payment: {
isInsurance: true,
@ -185,6 +188,9 @@ beforeEach(() => {
supportingItems: [],
},
experimentSettings: {},
vehicle: {
carId: "123",
},
};
});
afterEach(() => {
@ -195,11 +201,12 @@ afterEach(() => {
describe("schedule.vue...", () => {
describe("initial load", () => {
test("should pass arePagePrerequisitesValid with a mobile order and no providerNumber", () => {
test("should pass arePagePrerequisitesValid with a mobile CASH order and no providerNumber", () => {
// Arrange
const { wrapper } = setupMocks({});
store.getters.order.serviceLocation.appointmentType = "Mobile";
store.getters.order.serviceLocation.provider = null;
store.getters.order.serviceLocation.provider.policyNumber = null;
store.getters.payment.isInsurance = false;
// Act
const arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
@ -208,7 +215,7 @@ describe("schedule.vue...", () => {
expect(arePagePrerequisitesValid).toBe(true);
});
test("should pass arePagePrerequisitesValid with a inshop order and providerNumber", () => {
test("should pass arePagePrerequisitesValid with an inshop order and providerNumber", () => {
// Arrange
const { wrapper } = setupMocks({});
@ -328,7 +335,8 @@ describe("schedule.vue...", () => {
});
describe("beforeRouteEnter function... ", () => {
test("should call next() and call all functions within next", async () => {
// TODO: restore this test (temporarily removed it until CASH-845 is in QA then looping back)
xtest("should call next() and call all functions within next", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesInshop = {
@ -507,7 +515,8 @@ describe("schedule.vue...", () => {
});
});
test("forwardButtonAction should call route method navigateWithoutSaving", async () => {
// TODO: restore this test (temporarily removed it until CASH-845 is in QA then looping back)
xtest("forwardButtonAction should call route method navigateWithoutSaving", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.dispatchStoreAction = jest.fn(() => {

File diff suppressed because it is too large Load diff

View file

@ -1,25 +1,38 @@
<template>
<div class="time-slots-question">
<textBlock customText="Select a time:" justifyText="center" class="time-slot-header" />
<textBlock
v-show="durationTextBlockCopy"
:customText="durationTextBlockCopy"
justifyText="center"
typeStyle="small"
class="duration-text-block" />
<buttonQuestion
v-if="isDropOffAppointmentAvailable"
v-model="selectedAnswerForDropOffOrInshop"
:questionText="dropOffOrPickATimeQuestionLabelText"
:answers="answersForDropOffQuestion"
isRequired
insertOrLabelBeforeFinalOption
groupName="chooseDropOffOrInshop">
<div>
<alert
v-if="shouldDisplayDropOffAlert"
ref="alertDropoffInformation"
class="mb-4 drop-off-alert"
:cmsWidgetName="dropOffTimeSlotAlertCMSWidgetName"
alertClass="alert-info"
v-bind:isDismissible="false" />
</div>
</buttonQuestion>
<buttonQuestion
ref="buttonQuestion"
v-if="shouldDisplayTimeSlotQuestion"
buttonTypeString="timeSlotModalListButton"
:buttonTypeObject="timeSlotModalListButton"
class="mt-4"
class="mt-4 timeslots"
:class="[
isMobileAppointment ? 'is-mobile-appointment' : '',
isDropOffAppointment ? 'is-drop-off-appointment' : '',
isInshopOrDropOffAppointment ? 'is-inshop-or-drop-off-appointment' : '',
]"
:answers="availableTimeSlots"
groupName="chooseTimeSlot"
textPosition="text-center"
v-model="selectedRouteCode"
v-model="selectedAnswerForTimeSlots"
questionText="Available times:"
isRequired
validationRules="time-slot-required" />
<div
@ -59,6 +72,7 @@ import textBlock from "@/digital-components/text-block/text-block";
import buttonQuestion from "@/digital-components/button-question/button-question";
import checkboxQuestion from "@/digital-components/checkbox-question/checkbox-question";
import timeSlotModalListButton from "@/layouts/schedule/time-slot-modal-question/time-slot-modal-list-button/time-slot-modal-list-button.vue";
import alert from "@/ux-components/alert/alert.vue";
import experimentMixin from "@/mixins/experiment-mixin";
import { experimentSettings } from "@/constants/experiments";
import store from "@/store";
@ -66,7 +80,7 @@ import store from "@/store";
// Helpers
import { deepClone } from "@/helpers/object-helper";
import {
convertDateStringToDate,
isDropOffRouteCode,
militaryToTwelveHourTime,
} from "@/layouts/schedule/helpers/schedule-helper";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
@ -84,20 +98,15 @@ import {
RouteCodeFlags,
PREMIUM_TIME_SLOT_ID_FLAG,
PREMIUM_FEE_PART_TYPE,
cmsWidgetFieldMappings,
} from "@/constants/schedule-constants";
const cmsWidgetFieldMappings = {
MODAL_CLOSE_BUTTON: "FooterText",
SUPPLEMENTAL_INFORMATION: "BodyText",
TIME_SLOT_BUTTON: "HeaderText",
DISCLAIMER: "FooterText",
DURATION: "SubheaderText",
};
const PICK_A_TIME_BUTTON_VALUE = "pick-a-time-selected";
defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "time-slot-no-modal-question",
name: "time-slot-question",
emits: ["update:modelValue", "TimeSlotSelected", "click-event"],
props: {
modelValue: {
@ -120,6 +129,7 @@ export default {
dropoffCmsWidgetName: String,
sameDayDropOffCmsWidgetName: String,
overnightDropOffCmsWidgetName: String,
dropOffOrPickATimeQuestionCmsWidgetName: String,
appointmentType: String,
timeSlotsForSelectedDate: Object,
premiumAppointmentFee: Object,
@ -132,9 +142,11 @@ export default {
},
data() {
return {
selectedRouteCode: this.getSelectedRouteCode(),
selectedRouteCode: null,
timeSlotModalListButton: timeSlotModalListButton,
waitListRequested: this.getWaitListRequestedFromStore(),
selectedAnswerForDropOffOrInshop: null,
selectedAnswerForTimeSlots: null,
};
},
setup(props) {
@ -169,24 +181,31 @@ export default {
computed: {
supplementalInformationBlock() {
let appointmentTypeCmsWidgetName;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
if (
this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF ||
this.appointmentType === AppointmentTypeStrings.IN_SHOP ||
this.appointmentType === AppointmentTypeStrings.DROP_OFF
) {
return null;
} else if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
// This is planned to be used again for drop-off
// Wrote this to be ready for that eventuality, is untested and no HTML work done yet
// if (this.selectedRouteCode == null || !isDropOffRouteCode(this.selectedRouteCode)) {
// return null;
// } else {
// appointmentTypeCmsWidgetName =
// this.getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
// this.selectedRouteCode,
// true
// );
// }
} else {
appointmentTypeCmsWidgetName = this.selectedRouteCode?.includes(
PREMIUM_TIME_SLOT_ID_FLAG
)
? this.mobilePremiumCmsWidgetName
: this.mobileCmsWidgetName;
} else {
if (!this.selectedRouteCode) {
return null;
} else {
appointmentTypeCmsWidgetName =
this.getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
this.selectedRouteCode,
true
);
}
}
return this.getCmsContent(
@ -194,15 +213,16 @@ export default {
cmsWidgetFieldMappings.SUPPLEMENTAL_INFORMATION
);
},
footerCloseButtonText() {
return this.getCmsContent(
this.cmsWidgetName,
cmsWidgetFieldMappings.MODAL_CLOSE_BUTTON
);
dropOffOrPickATimeQuestionLabelText() {
return this.getCmsContent(this.dropOffOrPickATimeQuestionCmsWidgetName, "QuestionText");
},
pickATimeButtonLabelText() {
return this.getCmsContent(this.dropOffOrPickATimeQuestionCmsWidgetName, "Answers")[0]
.Text;
},
premiumAppointmentButtonText() {
return this.getCmsContent(
this.mobilePremiumCmsWidgetName,
this.dropOffOrPickATimeQuestionCmsWidgetName,
cmsWidgetFieldMappings.TIME_SLOT_BUTTON
);
},
@ -224,85 +244,40 @@ export default {
cmsWidgetFieldMappings.TIME_SLOT_BUTTON
);
},
dropoffDisclaimerText() {
return this.getCmsContent(this.dropoffCmsWidgetName, cmsWidgetFieldMappings.DISCLAIMER);
},
sameDayDropOffDisclaimerText() {
return this.getCmsContent(
this.sameDayDropOffCmsWidgetName,
cmsWidgetFieldMappings.DISCLAIMER
);
},
overnightDropOffDisclaimerText() {
return this.getCmsContent(
this.overnightDropOffCmsWidgetName,
cmsWidgetFieldMappings.DISCLAIMER
);
},
disclaimerTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
if (this.selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDay) {
return this.sameDayDropOffDisclaimerText;
} else {
return this.dropoffDisclaimerText;
}
} else if (this.selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropOffDisclaimerText;
} else {
return null;
}
} else {
return null;
}
},
dropOffDurationText() {
return this.getCmsContent(this.dropoffCmsWidgetName, cmsWidgetFieldMappings.DURATION);
},
sameDayDropoffDurationText() {
return this.getCmsContent(
this.sameDayDropOffCmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
},
overnightDropoffDurationText() {
return this.getCmsContent(
this.overnightDropOffCmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
},
inshopDurationText() {
const inshopDurationTextWithoutTime = this.getCmsContent(
this.cmsWidgetName,
cmsWidgetFieldMappings.DURATION
);
const inshopDurationTime = getDisplayTextForDurationLength(
this.estimatedServiceMinutesMinimum,
this.estimatedServiceMinutesMaximum
);
return `${inshopDurationTextWithoutTime} ${inshopDurationTime}`;
},
durationTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return null;
} else if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return this.inshopDurationText;
} else {
if (this.selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropoffDurationText;
} else if (this.selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDay) {
return this.sameDayDropoffDurationText;
} else {
return this.dropOffDurationText;
}
} else {
return null;
}
}
},
// None of this is used, but apparently could be re-activated if we go back to
// complete parity with Heritage. If that plan is dropped, delete this
// dropoffDisclaimerText() {
// return this.getCmsContent(this.dropoffCmsWidgetName, cmsWidgetFieldMappings.DISCLAIMER);
// },
// sameDayDropOffDisclaimerText() {
// return this.getCmsContent(
// this.sameDayDropOffCmsWidgetName,
// cmsWidgetFieldMappings.DISCLAIMER
// );
// },
// overnightDropOffDisclaimerText() {
// return this.getCmsContent(
// this.overnightDropOffCmsWidgetName,
// cmsWidgetFieldMappings.DISCLAIMER
// );
// },
// disclaimerTextBlockCopy() {
// if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
// if (this.selectedRouteCode?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
// if (this.isSameDay) {
// return this.sameDayDropOffDisclaimerText;
// } else {
// return this.dropoffDisclaimerText;
// }
// } else if (this.selectedRouteCode?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
// return this.overnightDropOffDisclaimerText;
// } else {
// return null;
// }
// } else {
// return null;
// }
// },
isSameDay() {
if (!this.timeSlotsForSelectedDate) {
return false;
@ -310,35 +285,51 @@ export default {
const todaysDate = new Date().toISOString().split("T")[0];
return this.selectedDate === todaysDate;
},
dateSelectedReadableDate() {
if (!this.timeSlotsForSelectedDate) {
return null;
}
// This conversion ensures we don't get get GMT induced date changes
const dateObject = convertDateStringToDate(this.timeSlotsForSelectedDate.date);
// Ex: Tuesday, April 22
return dateObject.toLocaleDateString("en-us", {
weekday: "long",
month: "long",
day: "numeric",
});
},
availableTimeSlots() {
if (!this.timeSlotsForSelectedDate) {
return null;
}
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
return this.getAvailableTimeSlotsForDropOff(
this.timeSlotsForSelectedDate.timeSlots
);
} else if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
return this.getAvailableTimeSlotsForMobile(this.timeSlotsForSelectedDate.timeSlots);
} else {
return this.getAvailableTimeSlotsForInshop(this.timeSlotsForSelectedDate.timeSlots);
}
},
answersForDropOffQuestion() {
if (!this.timeSlotsForSelectedDate) {
return null;
}
const dropOffQuestionAnswers = this.timeSlotsForSelectedDate.timeSlots
.filter((timeSlot) => {
return isDropOffRouteCode(timeSlot.id);
})
.map((timeSlot) => {
let buttonLabelValue;
if (timeSlot.id.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
buttonLabelValue = this.overnightDropoffButtonText;
} else if (this.isSameDay) {
buttonLabelValue = this.sameDayDropoffButtonText;
} else {
buttonLabelValue = this.dropoffButtonText;
}
return {
value: timeSlot.id,
buttonLabel: buttonLabelValue,
};
});
// Only add the pick a time button if inShop timeslots are available
if (
this.getAvailableTimeSlotsForInshop(this.timeSlotsForSelectedDate.timeSlots)
.length !== 0
) {
dropOffQuestionAnswers.push({
value: PICK_A_TIME_BUTTON_VALUE,
buttonLabel: this.pickATimeButtonLabelText,
});
}
return dropOffQuestionAnswers;
},
hasWaitListExperiment() {
return experimentMixin.methods.hasSettingEqualTo(
experimentSettings.DISPLAY_WAITLIST,
@ -357,17 +348,44 @@ export default {
isMobileAppointment() {
return this.appointmentType === AppointmentTypeStrings.MOBILE;
},
isDropOffAppointment() {
return this.appointmentType === AppointmentTypeStrings.DROP_OFF;
isInshopOrDropOffAppointment() {
return this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF;
},
shouldDisplayDropOffAlert() {
return isDropOffRouteCode(this.selectedAnswerForDropOffOrInshop);
},
dropOffTimeSlotAlertCMSWidgetName() {
if (this.selectedAnswerForDropOffOrInshop.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return "OvernightDropOffTimeSlotAlert";
} else if (this.isSameDay) {
return "SameDayDropOffTimeSlotAlert";
} else {
return "DropOffTimeSlotAlert";
}
},
isDropOffAppointmentAvailable() {
return (
this.answersForDropOffQuestion !== null &&
this.answersForDropOffQuestion.length !== 0 &&
this.answersForDropOffQuestion[0].value !== PICK_A_TIME_BUTTON_VALUE
);
},
shouldDisplayTimeSlotQuestion() {
return (
this.selectedAnswerForDropOffOrInshop == PICK_A_TIME_BUTTON_VALUE ||
!this.isDropOffAppointmentAvailable
);
},
},
methods: {
initializeComponent() {
this.setSelectedRouteCodeFromParent();
},
async setSelectedTimeSlot() {
this.$emit(
"update:modelValue",
this.getSelectedTimeSlotInfoObject(this.selectedRouteCode)
);
this.$emit("TimeSlotSelected");
},
getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
selectedRouteCode,
@ -382,31 +400,17 @@ export default {
}
},
getAvailableTimeSlotsForInshop(timeSlotsForSelectedDate) {
return timeSlotsForSelectedDate.map((timeSlot) => {
const readableTime = militaryToTwelveHourTime(timeSlot.startTime);
return {
value: timeSlot.id,
buttonLabel: readableTime,
};
});
},
getAvailableTimeSlotsForDropOff(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
let buttonLabelValue;
if (timeSlot.id.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
buttonLabelValue = this.overnightDropoffButtonText;
} else if (this.isSameDay) {
buttonLabelValue = this.sameDayDropoffButtonText;
} else {
buttonLabelValue = this.dropoffButtonText;
}
return {
value: timeSlot.id,
buttonLabel: buttonLabelValue,
};
});
return availableTimeSlots;
return timeSlotsForSelectedDate
.filter((timeSlot) => {
return !isDropOffRouteCode(timeSlot.id);
})
.map((timeSlot) => {
const readableTime = militaryToTwelveHourTime(timeSlot.startTime);
return {
value: timeSlot.id,
buttonLabel: readableTime,
};
});
},
getAvailableTimeSlotsForMobile(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
@ -443,29 +447,34 @@ export default {
},
};
},
getSelectedRouteCode() {
let selectedRouteCode;
if (!this.modelValue?.timeSlot) {
selectedRouteCode = null;
setSelectedRouteCodeFromParent() {
if (!this.modelValue?.timeSlot?.routeCode) {
return;
}
const routeCodeFromParent = this.modelValue.timeSlot.routeCode;
if (this.modelValue?.isPremiumAppointment) {
selectedRouteCode = this.addPremiumFlagToInput(
this.modelValue?.timeSlot?.routeCode
);
// Mobile Only
this.selectedAnswerForTimeSlots = this.addPremiumFlagToInput(routeCodeFromParent);
} else {
selectedRouteCode = this.modelValue?.timeSlot?.routeCode;
if (isDropOffRouteCode(routeCodeFromParent)) {
this.selectedAnswerForDropOffOrInshop = routeCodeFromParent;
} else {
this.selectedAnswerForDropOffOrInshop = PICK_A_TIME_BUTTON_VALUE;
this.selectedAnswerForTimeSlots = routeCodeFromParent;
}
}
return selectedRouteCode;
},
getWaitListRequestedFromStore() {
return store.getters.order.customer?.waitListRequested;
},
autoSelectTimeSlotIfOnlyOneIsAvailable() {
const numberOfOptions = this.availableTimeSlots?.length;
const numberOfOptions = this.timeSlotsForSelectedDate.timeSlots?.length;
if (numberOfOptions === 1) {
this.selectedRouteCode = this.availableTimeSlots[0].value;
if (this.availableTimeSlots.length > 0) {
this.selectedAnswerForTimeSlots = this.availableTimeSlots[0].value;
} else {
this.selectedAnswerForDropOffOrInshop = this.answersForDropOffQuestion[0].value;
}
}
},
addPremiumFlagToInput(routeCode) {
@ -479,7 +488,6 @@ export default {
if (routeCodeIncludesPremium) {
routeCode = this.removePremiumFlagFromInput(routeCode);
}
const timeSlot = this.timeSlotsForSelectedDate?.timeSlots?.find(
(slot) => slot.id == routeCode
);
@ -540,24 +548,37 @@ export default {
this.autoSelectTimeSlotIfOnlyOneIsAvailable();
},
},
selectedRouteCode(newVal) {
if (newVal) {
selectedRouteCode(newValue) {
if (newValue) {
this.setSelectedTimeSlot();
}
},
selectedAnswerForDropOffOrInshop(newValue) {
if (newValue == PICK_A_TIME_BUTTON_VALUE) {
this.selectedRouteCode = null;
this.setSelectedTimeSlot();
} else {
this.selectedAnswerForTimeSlots = null;
this.selectedRouteCode = newValue;
}
},
selectedAnswerForTimeSlots(newValue) {
if (newValue) {
this.selectedRouteCode = newValue;
}
},
},
components: {
// Removed modal
textBlock,
buttonQuestion,
checkboxQuestion,
alert,
},
};
</script>
<style lang="scss">
.time-slots-question {
border-top: 1px solid $gray-500;
margin-top: 1.5rem;
.time-slot-header {
@ -623,5 +644,23 @@ export default {
text-align: left;
margin: 1.5rem 0 0;
}
.drop-off-alert {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A");
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
padding: 0.5rem 0.5rem 0.5rem 1.5rem;
gap: 0.25rem;
background-color: #e4f1f7;
.alert-heading {
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
}
</style>

View file

@ -59,7 +59,7 @@ afterEach(() => {
});
describe("appointment-type-question.vue", () => {
it("Should display all options if in-shop, mobile, and dropoff are available", async () => {
it("Should display all options if in-shop and mobile are available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
@ -67,7 +67,6 @@ describe("appointment-type-question.vue", () => {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: true,
isServiceableDropoff: true,
},
mountOptions: {
attachTo: document.body,
@ -90,13 +89,6 @@ describe("appointment-type-question.vue", () => {
SubWidgetName: "",
Text: "In-shop",
},
{
AnswerImageUrl: "",
Name: "Dropoff",
SubText: "",
SubWidgetName: "",
Text: "Drop-off",
},
]);
});
@ -108,7 +100,6 @@ describe("appointment-type-question.vue", () => {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: false,
isServiceableDropoff: false,
},
mountOptions: {
attachTo: document.body,
@ -127,74 +118,6 @@ describe("appointment-type-question.vue", () => {
]);
});
it("Should display only the In-Shop and Drop-Off answers when mobile service is not available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: false,
isServiceableDropoff: true,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([
{
AnswerImageUrl: "",
Name: "Inshop",
SubText: "",
SubWidgetName: "",
Text: "In-shop",
},
{
AnswerImageUrl: "",
Name: "Dropoff",
SubText: "",
SubWidgetName: "",
Text: "Drop-off",
},
]);
});
it("Should display only the In-Shop and Drop-Off answers when mobile service is not available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: false,
isServiceableDropoff: true,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([
{
AnswerImageUrl: "",
Name: "Inshop",
SubText: "",
SubWidgetName: "",
Text: "In-shop",
},
{
AnswerImageUrl: "",
Name: "Dropoff",
SubText: "",
SubWidgetName: "",
Text: "Drop-off",
},
]);
});
it("Should display only the Mobile answer when only mobile service is available", async () => {
// Arrange/Act
const { wrapper } = setupMocks({
@ -203,7 +126,6 @@ describe("appointment-type-question.vue", () => {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: false,
isServiceableMobile: true,
isServiceableDropoff: false,
},
mountOptions: {
attachTo: document.body,
@ -221,46 +143,6 @@ describe("appointment-type-question.vue", () => {
},
]);
});
it("Should not display Drop off answer when it is repair order", async () => {
// Arrange/Act
store.getters = {
damage: {
isRepair: true,
},
};
const { wrapper } = setupMocks({
mixins: [mockMixin],
props: {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: true,
isServiceableMobile: true,
isServiceableDropoff: true,
},
mountOptions: {
attachTo: document.body,
},
});
// Assert
expect(wrapper.vm.answersToDisplay).toEqual([
{
AnswerImageUrl: "",
Name: "Mobile",
SubText: "",
SubWidgetName: "",
Text: "Mobile",
},
{
AnswerImageUrl: "",
Name: "Inshop",
SubText: "",
SubWidgetName: "",
Text: "In-shop",
},
]);
});
it("Should display no answers if neither in-shop nor mobile service are available", async () => {
// Arrange/Act
@ -270,7 +152,6 @@ describe("appointment-type-question.vue", () => {
cmsWidgetName: cmsWidgetName,
isServiceableInshop: false,
isServiceableMobile: false,
isServiceableDropoff: false,
},
mountOptions: {
attachTo: document.body,

View file

@ -33,7 +33,6 @@ export default {
cmsWidgetName: String,
isServiceableMobile: Boolean,
isServiceableInshop: Boolean,
isServiceableDropoff: Boolean,
mobileFeeApplies: Boolean,
labelBold: {
type: Boolean,
@ -52,16 +51,13 @@ export default {
answersToDisplay() {
const shouldShowMobile = this.isServiceableMobile;
const shouldShowInshop = this.isServiceableInshop;
const shouldShowDropoff =
this.isServiceableDropoff && !this.$store.getters.damage.isRepair;
const zipCode = this.zipCode;
var answers = this.answersFromCms
? this.answersFromCms.filter((answer) => {
return (
(answer.Name == AppointmentTypeStrings.IN_SHOP && shouldShowInshop) ||
(answer.Name == AppointmentTypeStrings.MOBILE && shouldShowMobile) ||
(answer.Name == AppointmentTypeStrings.DROP_OFF && shouldShowDropoff)
(answer.Name == AppointmentTypeStrings.MOBILE && shouldShowMobile)
);
})
: [];
@ -87,9 +83,7 @@ export default {
return this.isServiceableMobile && !this.isServiceableInshop;
},
isInshopOnly() {
return (
this.isServiceableInshop && !this.isServiceableMobile && !this.isServiceableDropoff
);
return this.isServiceableInshop && !this.isServiceableMobile;
},
},
watch: {

View file

@ -0,0 +1,848 @@
<!-- OLD SERVICE LOCATION BEGINS -->
<!-- TODO: REMOVE THIS FILE ONCE CASH-803 (SERVICE-LOCATION AND SCHEDULE PAGE COMBINATION) HAS BEEN VETTED -->
<template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm" v-slot="{ meta }">
<loadingModal ref="loadingModal" />
<div class="container-fluid page-container-grouped-styles">
<div class="row justify-content-center">
<div class="col-md-6">
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4 mt-4">
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
<serviceZipModalQuestion
editScreenReaderTextCmsWidgetName="ScreenReaderZipEditWidget"
v-model="serviceZipCodeQuestion"
ref="serviceZipCodeQuestion"
:carId="carId"
:isVehicleHeavyTruck="isVehicleHeavyTruck"
:mobileFeePart="mobileFeePart"
@updated-mobile-fee-part="setMobileFeePart"
@updated-recycle-fee-part="setRecycleFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
@updated-bill-to-account-number="setBillToAccountNumber"
linkWidgetName="ServiceZipLinkWidget"
modalWidgetName="ServiceZipModalWidget" />
<alert
ref="alertMilitaryBaseZip"
class="my-5"
cmsWidgetName="AlertMilitaryBaseZipWidget"
v-if="displayMilitaryZipAlert"
alertClass="alert-warning" />
<alert
ref="alertMobileOnly"
class="my-5"
cmsWidgetName="AlertMobileOnlyWidget"
v-if="displayServiceableMobileOnly"
alertClass="alert-warning" />
<alert
ref="alertRecalNoMobile"
class="my-5"
cmsWidgetName="AlertRecalNoMobileWidget"
v-if="displayRecalibrationWarning"
@text-link-clicked="openModalAction"
alertClass="alert-warning" />
<alert
ref="alertInshopOnly"
class="my-5"
cmsWidgetName="AlertInshopOnlyWidget"
v-if="displayServiceableInshopOnly"
alertClass="alert-warning" />
<alert
ref="alertNoShops"
class="my-5"
cmsWidgetName="AlertNoShopsWidget"
v-if="displayNoShopsAlert"
alertClass="alert-warning" />
<alert
ref="alertMobileFeeFree"
class="my-5"
cmsWidgetName="AlertMobileFeeFreeWidget"
v-if="showMobileFreeAlert"
alertClass="alert-success" />
</div>
</div>
<div class="row justify-content-center appointment-type">
<div class="col-md-6">
<appointmentTypeQuestion
v-model="selectedAppointmentType"
v-show="isAppointmentTypeDisplayed"
:isServiceableMobile="isServiceableMobile"
:isServiceableInshop="isServiceableInshop"
:isServiceableDropoff="isServiceableDropoff"
:isDisplayed="isAppointmentTypeDisplayed"
:mobileFeeApplies="mobileFeeApplies"
:zipCode="zipCode"
ref="appointmentTypeQuestion"
groupName="appointmentTypeQuestion"
cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required"
labelBold="true" />
</div>
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<mobileLocationModalQuestions
customComponentId="mobileLocationQuestions"
v-show="selectedAppointmentType === 'Mobile'"
v-model="mobileLocationQuestions"
:mobileFeePart="mobileFeePart"
:mobileFeeApplies="mobileFeeApplies"
@updated-mobile-fee-part="setMobileFeePart"
@updated-recycle-fee-part="setRecycleFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
@updated-mobile-ctu="setCtuForMobile"
validationRules="mobile-location-required"
ref="mobileLocationQuestions"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget"
@setMobileLocation="setMobileLocationQuestions"
@set-mobile-location-invalid="setMobileLocationInValid" />
<shopQuestion
ref="shopQuestion"
v-show="isShopQuestionDisplayed"
v-model="selectedProvider"
:selectedAppointmentType="selectedAppointmentType"
:shopProviderData="shopProviderData"
:isDisplayed="isShopQuestionDisplayed"
cmsWidgetName="ShopQuestionWidget" />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
:isForwardActionDisabled="!meta.valid || isForwardActionDisabled"
@back-clicked="backButtonAction"
@ForwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</Form>
</template>
<script>
// Components
import alert from "@/ux-components/alert/alert";
import serviceZipModalQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-modal-question";
import mobileLocationModalQuestions from "@/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions";
import appointmentTypeQuestion from "@/layouts/service-location/appointment-type-question/appointment-type-question";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form } from "vee-validate";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
import experimentMixin from "@/mixins/experiment-mixin.js";
import { experimentSettings } from "@/constants/experiments";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import {
getPricedMobileFeePart,
getServiceabilityDetails,
getShopProviderData,
getZipCodeData,
} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { applicationConfig } from "@/constants/application-config";
import { Provider } from "@/layouts/service-location/classes/provider";
import { partNumberStrings } from "@/constants/part-number-strings";
import store from "@/store";
// Validation
import { defineRule } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
const MOBILE_FEE_PART_TYPE = "MOBILE FEE";
// DEFINE VALIDATION RULES
defineRule("mobile-location-required", (value) => {
const addressQuestionsValues = Object.values(
[
value.addressQuestions.streetAddress,
value.addressQuestions.city,
value.isVehicleProtected,
] || {}
);
const filledFields = addressQuestionsValues.filter(
(val) => val !== null && val !== undefined && val !== ""
);
if (
value.isMobileSelected &&
filledFields.length > 0 &&
filledFields.length < addressQuestionsValues.length
) {
return errorMessages.MOBILE_LOCATION_REQUIRED;
}
return true;
});
export default {
name: "service-location",
data() {
return {
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(),
carId: this.getCarIdfromStore(),
city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
isVehicleHeavyTruck: this.getIsVehicleHeavyTruckFromStore(),
isVehicleProtected: this.getIsVehicleProtectedFromStore(),
isGlassServiceableInshop: null,
isRecalibrationServiceableInshop: null,
isGlassServiceableDropoff: null,
isRecalibrationServiceableDropoff: null,
isGlassServiceableMobile: null,
isRecalibrationServiceableMobile: null,
selectedAppointmentType: this.getSelectedAppointmentType(),
selectedProvider: this.getSelectedProvider(),
mobileFeePart: null,
recycleFeePart: null,
zipContainsMilitaryBase: false,
zipCodeCtu: null,
billToAccountNumber: null,
shopProviderData: null,
navigatingForward: false,
isMobileAddressValid: true,
};
},
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.name);
const serviceZipCode = store.getters.order.serviceLocation.zipCode;
const zipCodeDataPromise = getZipCodeData(serviceZipCode);
const serviceabilityDetailsPromise = getServiceabilityDetails(
serviceZipCode,
null,
"service-location"
);
const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode, "service-location");
const shopProviderDataPromise = getShopProviderData(serviceZipCode); // shopQuestion.methods.loadInitialData(serviceZipCode);
// Settle promises and get results
const promiseResultMap = [
{
resultKey: "cmsContent",
promise: cmsContentPromise,
},
{
resultKey: "zipCodeData",
promise: zipCodeDataPromise,
},
{
resultKey: "mobileFeePart",
promise: mobileFeePartPromise,
},
{
resultKey: "serviceabilityDetails",
promise: serviceabilityDetailsPromise,
},
{
resultKey: "shopProviderData",
promise: shopProviderDataPromise,
},
];
const resultMap = await settleAllPromises(promiseResultMap);
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.setData(
resultMap.zipCodeData,
resultMap.serviceabilityDetails,
resultMap.mobileFeePart,
resultMap.shopProviderData
);
});
},
computed: {
serviceZipCodeQuestion: {
get: function () {
return {
state: this.state,
zipCode: this.zipCode,
zipCodeCtu: this.zipCodeCtu,
};
},
set: function (newValue) {
if (newValue.zipCode !== this.zipCode) {
this.resetMobileLocation();
this.selectedAppointmentType = null;
this.selectedProvider = new Provider();
}
this.state = newValue.state;
this.zipCode = newValue.zipCode;
this.zipCodeCtu = newValue.zipCodeCtu;
this.$nextTick();
},
},
mobileLocationQuestions: {
get: function () {
return {
addressQuestions: {
streetAddress: this.streetAddress,
apartmentNumberOrBusinessName: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode,
},
isVehicleProtected: this.isVehicleProtected,
isMobileSelected: this.selectedAppointmentType == AppointmentTypeStrings.MOBILE,
};
},
},
isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) {
return (
(this.isGlassServiceableMobile && this.isRecalibrationServiceableMobile) ||
this.isMobileStaticRecalibrationApplicable
);
} else {
return this.isGlassServiceableMobile;
}
},
isMobileStaticRecalibrationApplicable() {
return (
this.displayMSR &&
this.mobileFeePart?.partNumber == partNumberStrings.MOBILE_STATIC_RECAL_FEE &&
(this.isCashItacNoComp || this.mobileFeePart?.isInsurable)
);
},
displayMSR() {
return (
experimentMixin.methods
.getSettingValue(experimentSettings.DISPLAY_MSR)
?.toLowerCase() === "true"
);
},
isCashItacNoComp() {
return !this.isInsurance || this.isITAC || this.isNoComp;
},
mobileFeeHasPrice() {
return (
this.mobileFeePart?.laborAmount > 0 ||
this.mobileFeePart?.sellingPrice > 0 ||
this.mobileFeePart?.kitPrice > 0
);
},
mobileFeeApplies() {
if (this.isMobileStaticRecalibrationApplicable && this.mobileFeeHasPrice) {
return this.isCashItacNoComp;
} else {
return this.mobileFeeHasPrice;
}
},
showMobileFreeAlert() {
if (this.isServiceableMobile && !this.isInsurance && !this.mobileFeeHasPrice) {
return true;
}
return false;
},
isServiceableInshop() {
if (this.isRecalibrationServiceableInshop !== null) {
return this.isGlassServiceableInshop && this.isRecalibrationServiceableInshop;
} else {
return this.isGlassServiceableInshop;
}
},
isServiceableDropoff() {
if (this.isRecalibrationServiceableDropoff !== null) {
return this.isGlassServiceableDropoff && this.isRecalibrationServiceableDropoff;
} else {
return this.isGlassServiceableDropoff;
}
},
isShopQuestionDisplayed() {
return (
this.selectedAppointmentType === "Inshop" ||
this.selectedAppointmentType === "Dropoff"
);
},
isAppointmentTypeDisplayed() {
return this.zipCode && !this.displayNoShopsAlert;
},
requiresInshopRecalibration() {
// Specifically check for isRecalibrationServiceableMobile === false, not null or true.
return (
this.isServiceableInshop &&
this.isGlassServiceableMobile &&
this.isRecalibrationServiceableMobile === false &&
!this.isMobileStaticRecalibrationApplicable
);
},
displayRecalibrationWarning() {
return this.requiresInshopRecalibration;
},
displayServiceableInshopOnly() {
return (
!this.displayRecalibrationWarning &&
this.isServiceableInshop &&
!this.isServiceableMobile
);
},
displayMilitaryZipAlert() {
return this.zipContainsMilitaryBase && this.isServiceableMobile;
},
displayServiceableMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
},
displayNoShopsAlert() {
return !this.isServiceableInshop && !this.isServiceableMobile;
},
recalibrationInformationModal() {
return this.$refs.recalibrationInformationModal;
},
isInsurance() {
return store.getters.payment.isInsurance;
},
isITAC() {
return store.getters.order.policy.isItac;
},
isNoComp() {
return store.getters.order.policy.isNoComp;
},
isForwardActionDisabled() {
return this.displayNoShopsAlert || !this.isMobileAddressValid;
},
},
methods: {
arePagePrerequisitesValid() {
// insurance drops the recycle fee on replace orders so it won't be in supportingItems
if (store.getters.payment.isInsurance) {
if (
store.getters.payment.parentAccountNumber !==
applicationConfig.CASH_PARENT_ACCOUNT_NUMBER &&
store.getters.order.policy.policyNumber &&
store.getters.order.serviceLocation.zipCode
) {
return true;
} else {
// prettier-ignore
{
console.log(new Date() + "Insurance: servce-location invalid prereqs");
console.log(new Date() + "store.getters.payment.parentAccountNumber:" + store.getters.payment.parentAccountNumber);
console.log(new Date() + "store.getters.order.policy.policyNumber:" + store.getters.order.policy.policyNumber);
console.log(new Date() + "store.getters.order.serviceLocation.zipCode:" + store.getters.order.serviceLocation.zipCode);
}
return false;
}
} else {
if (
store.getters.lineItems.supportingItems &&
store.getters.order.serviceLocation.zipCode
) {
return true;
} else {
// prettier-ignore
{
console.log(new Date() + "Cash: servce-location invalid prereqs");
console.log(new Date() + "store.getters.lineItems.supportingItems:" + JSON.stringify(store.getters.lineItems.supportingItems));
console.log(new Date() + "store.getters.order.serviceLocation.zipCode:" + store.getters.order.serviceLocation.zipCode);
}
return false;
}
}
},
setData(zipCodeData, serviceabilityDetails, mobileFeePart, shopProviderData) {
if (zipCodeData) {
this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase;
this.zipCodeCtu = zipCodeData.zipCodeCtu;
}
if (serviceabilityDetails) {
this.setServiceabilityDetails(serviceabilityDetails);
}
if (mobileFeePart) {
this.mobileFeePart = mobileFeePart;
}
if (shopProviderData) {
this.shopProviderData = shopProviderData;
}
var gaLabel = this.GaLabels.NO;
if (this.isServiceableMobile) {
gaLabel = this.GaLabels.YES;
}
this.pushEventToGA(
this.GaCategories.APPOINTMENT,
this.GaActions.MOBILE_AVAILABLE,
gaLabel,
true
);
},
setContainsMilitaryBase(val) {
if (this.zipContainsMilitaryBase !== val) {
this.zipContainsMilitaryBase = val;
}
},
setBillToAccountNumber(val) {
this.billToAccountNumber = val;
},
setCtuForMobile(val) {
this.zipCodeCtu = val;
},
setMobileFeePart(mobileFeePart) {
this.mobileFeePart = mobileFeePart;
},
setRecycleFeePart(recycleFeePart) {
this.recycleFeePart = recycleFeePart;
},
//creating async function as the computed property can not directly handle asynchronous operations or promises.
async setMobileLocationQuestions(newValue) {
var newZipCode = newValue.addressQuestions.zipCode;
if (newZipCode !== this.zipCode) {
await getShopProviderData(newZipCode).then((result) => {
if (this.isServiceableMobile) {
this.shopProviderData = result.data;
this.selectedProvider = new Provider(
this.shopProviderData.mobileProviderNumber
);
}
});
}
if (this.isServiceableMobile) {
this.setMobileLocation(newValue);
this.setMobileLocationInValid(false);
} else {
await getZipCodeData(newZipCode).then((zipCodeData) => {
this.serviceZipCodeQuestion = {
state: zipCodeData.state,
zipCode: newZipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
};
});
}
},
getCarIdfromStore() {
return store.getters.vehicle.carId;
},
getServiceAddressFromStore() {
return store.getters.order.serviceLocation.address;
},
getServiceAddress2FromStore() {
return store.getters.order.serviceLocation.address2;
},
getServiceCityFromStore() {
return store.getters.order.serviceLocation.city;
},
getServiceStateFromStore() {
return store.getters.order.serviceLocation.state;
},
getServiceZipCodeFromStore() {
return store.getters.order.serviceLocation.zipCode;
},
getIsVehicleHeavyTruckFromStore() {
return store.getters.order.vehicle?.isBigTruck ?? false;
},
getIsVehicleProtectedFromStore() {
return store.getters.order.serviceLocation.isVehicleProtected;
},
getSelectedAppointmentType() {
return store.getters.order.serviceLocation.appointmentType;
},
getSelectedProvider() {
return store.getters.order.serviceLocation.provider;
},
resetMobileLocation() {
this.streetAddress = "";
this.apartmentNumberOrBusinessName = "";
this.city = "";
this.isVehicleProtected = null;
},
setServiceabilityDetails(serviceabilityDetails) {
this.isGlassServiceableInshop = serviceabilityDetails.isGlassServiceableInshop;
this.isRecalibrationServiceableInshop =
serviceabilityDetails.isRecalibrationServiceableInshop;
this.isGlassServiceableDropoff = serviceabilityDetails.isGlassServiceableDropoff;
this.isRecalibrationServiceableDropoff =
serviceabilityDetails.isRecalibrationServiceableDropoff;
this.isGlassServiceableMobile = serviceabilityDetails.isGlassServiceableMobile;
this.isRecalibrationServiceableMobile =
serviceabilityDetails.isRecalibrationServiceableMobile;
},
setMobileLocation(mobileLocation) {
this.streetAddress = mobileLocation.addressQuestions.streetAddress;
this.apartmentNumberOrBusinessName =
mobileLocation.addressQuestions.apartmentNumberOrBusinessName;
this.city = mobileLocation.addressQuestions.city;
this.state = mobileLocation.addressQuestions.state;
this.zipCode = mobileLocation.addressQuestions.zipCode;
this.isVehicleProtected = mobileLocation.isVehicleProtected;
},
async reloadShopData(zipCode) {
await this.$refs.shopQuestion.reloadShopData(zipCode);
},
openRecalibrationInformationModal() {
this.recalibrationInformationModal.openModal();
},
backButtonAction() {
const payment = store.getters.order.payment;
if (
payment?.isInsurance &&
(payment?.insuranceCoverage?.isVerified ||
store.getters.order.referralNumber.length === 6)
) {
navigateToHeritageFunnel({
shouldSaveSession: false,
pageNameToLog: "service-location",
navType: "back",
});
} else {
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_BACK,
this.pageName
);
}
},
updateAndSaveIsMSRFeeApplicable() {
const isMSRFeeApplicable =
this.isMobileStaticRecalibrationApplicable &&
this.selectedAppointmentType == "Mobile";
this.dispatchStoreAction(
this.storeActions.SAVE_IS_MSR_FEE_APPLICABLE,
isMSRFeeApplicable
);
},
updateAndSaveSupportingItems() {
let supportingItems = store.getters.lineItems.supportingItems;
let shouldSaveSupportingItems = false;
supportingItems =
!supportingItems && this.isMobileStaticRecalibrationApplicable
? []
: supportingItems;
// for insurance orders, fees can get removed causing supportingitems to be null or empty
if (!supportingItems) {
return;
}
// update recyle fee price
if (this.recycleFeePart) {
const recycleFeeIndex = supportingItems.findIndex(
(item) => item.partNumber == partNumberStrings.RECYCLE_FEE
);
if (recycleFeeIndex >= 0) {
supportingItems[recycleFeeIndex].laborAmount = this.recycleFeePart.laborAmount;
supportingItems[recycleFeeIndex].sellingPrice =
this.recycleFeePart.sellingPrice;
supportingItems[recycleFeeIndex].kitPrice = this.recycleFeePart.kitPrice;
shouldSaveSupportingItems = true;
}
}
// if we have a mobile fee, then save/update supporting items
if (this.selectedAppointmentType == "Mobile") {
const mobileFeeIndex = supportingItems.findIndex(
(item) => item.partType == MOBILE_FEE_PART_TYPE
);
// If it already exists, update the price with latest data
if (mobileFeeIndex >= 0) {
supportingItems[mobileFeeIndex].laborAmount = this.mobileFeePart.laborAmount;
supportingItems[mobileFeeIndex].sellingPrice = this.mobileFeePart.sellingPrice;
supportingItems[mobileFeeIndex].kitPrice = this.mobileFeePart.kitPrice;
} else {
if (this.mobileFeePart !== null) supportingItems.push(this.mobileFeePart);
}
shouldSaveSupportingItems = true;
} else {
// if it's not a mobile, then make sure we remove any that may have been added
const removeMobileFeeIndex = supportingItems?.findIndex(
(item) => item.partType == MOBILE_FEE_PART_TYPE
);
if (removeMobileFeeIndex >= 0) {
supportingItems.splice(removeMobileFeeIndex, 1);
shouldSaveSupportingItems = true;
}
}
// Use shouldSaveSupportingItems flag to determine if we need to save supporting items. Prevents unnecessary/multiple saves
if (shouldSaveSupportingItems) {
this.dispatchStoreAction(
this.storeActions.SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING,
supportingItems,
false
);
}
},
async forwardButtonAction() {
this.navigatingForward = true;
var ctu = this.zipCodeCtu;
if (
this.selectedAppointmentType == AppointmentTypeStrings.MOBILE &&
this.selectedProvider &&
this.selectedProvider.address
) {
this.selectedProvider.address.streetAddress = null;
this.selectedProvider.address.city = null;
this.selectedProvider.address.state = null;
this.selectedProvider.address.zipCode = null;
this.selectedProvider.address.zipCodeCtu = null;
} else {
// if we have a provider.zipCodeCtu that's different than the servicelocation.zipCodeCtu then they
// may have selected a shop in a different ctu. change the servicelocation zip/ctu if different
if (this.zipCodeCtu != this.selectedProvider.address.zipCodeCtu) {
ctu = this.selectedProvider.address.zipCodeCtu;
}
this.resetMobileLocation();
}
await this.dispatchStoreAction(
this.storeActions.SAVE_SERVICE_LOCATION,
{
address: this.streetAddress,
address2: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode,
zipCodeCtu: ctu,
appointmentType: this.selectedAppointmentType,
isVehicleProtected: this.isVehicleProtected,
provider: {
providerNumber: this.selectedProvider?.providerNumber,
address: {
streetAddress: this.selectedProvider?.address?.streetAddress,
city: this.selectedProvider?.address?.city,
state: this.selectedProvider?.address?.state,
zipCode: this.selectedProvider?.address?.zipCode,
zipCodeCtu: this.selectedProvider?.address?.zipCodeCtu,
},
},
},
false
);
if (this.billToAccountNumber) {
this.dispatchStoreAction(
this.storeActions.SAVE_BILL_TO_ACCOUNT_NUMBER,
this.billToAccountNumber,
false
);
}
this.updateAndSaveSupportingItems();
this.updateAndSaveIsMSRFeeApplicable();
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
);
},
setMobileLocationInValid(isMobileLocationInValid) {
this.isMobileAddressValid = !isMobileLocationInValid;
},
},
watch: {
zipCode: {
handler(newValue) {
if (!this.navigatingForward) {
getShopProviderData(this.zipCode).then(async (result) => {
this.shopProviderData = result.data;
if (this.selectedAppointmentType === "Mobile") {
this.selectedProvider = new Provider(
this.shopProviderData.mobileProviderNumber
);
} else {
this.selectedProvider = new Provider();
}
});
}
},
},
selectedAppointmentType: {
handler(newValue) {
if (newValue === "Mobile") {
this.selectedProvider = new Provider(
this.shopProviderData.mobileProviderNumber
);
if (this.zipCode == this.getServiceZipCodeFromStore()) {
//restore mobile address from store in case user make changes to address but not commit it.
this.streetAddress = this.getServiceAddressFromStore();
this.apartmentNumberOrBusinessName = this.getServiceAddress2FromStore();
this.city = this.getServiceCityFromStore();
this.isVehicleProtected = this.getIsVehicleProtectedFromStore();
this.$refs.mobileLocationQuestions.resetAlerts();
} else {
this.resetMobileLocation();
}
} else {
this.selectedProvider = new Provider();
}
},
},
},
components: {
alert,
serviceZipModalQuestion,
appointmentTypeQuestion,
mobileLocationModalQuestions,
funnelHeader,
navbar,
funnelSubHeader,
Form,
loadingModal,
contentGroupModal,
shopQuestion,
},
};
</script>
<style lang="scss">
.appointment-type-question {
.button-question {
.col {
@include media-breakpoint-up(md) {
padding: 0 0.75rem;
}
@include media-breakpoint-up(xl) {
width: 33.3333333%;
flex: 0 0 auto;
}
}
}
}
.question-text {
& > span {
text-align: center;
color: $black;
}
}
</style>
<!-- OLD SERVICE LOCATION ENDS -->

View file

@ -219,7 +219,13 @@ describe("service-location.vue", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
/* ******NOTE: if we ever want to revert back to the old way of service location using shopQuestion instead of shopQuestionPopup uncomment all 30 of the lines in this file that read - wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn(); - , and commment all of the blocks of code referencing shopQuestionPopup directly under them. Also remove reference to shopQuestionPopup in the setupMocks function*/
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({ ref: "shopQuestionPopup" });
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
const mobileFeePart = {
partNumber: "MOBILE FEE",
@ -314,57 +320,6 @@ describe("service-location.vue", () => {
expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeQuestion);
});
test("resets mobile location when service zip code is updated", () => {
// Arrange
const { wrapper } = setupMocks({});
const mobileLocationQuestionsComponent = wrapper.findComponent({
ref: "mobileLocationQuestions",
});
mobileLocationQuestionsComponent.resetComponent = jest.fn();
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
serviceZipCodeComponent.resetMobileFeePart = jest.fn();
const newServiceZipCodeQuestion = {
zipCode: "61606",
state: "IL",
zipCodeCtu: "01526",
};
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Sulgrave Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
isVehicleProtected: true,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "IL",
zipCode: "61606",
},
isVehicleProtected: null,
isMobileSelected: false,
};
// Act
serviceZipCodeComponent.vm.$emit("update:modelValue", newServiceZipCodeQuestion);
// Assert
expect(wrapper.vm.mobileLocationQuestions).toStrictEqual(newMobileLocationQuestions);
});
test("resets appointment type selection when service zip code is updated by service zip modal", () => {
// Arrange
const { wrapper } = setupMocks({});
@ -424,190 +379,6 @@ describe("service-location.vue", () => {
});
});
describe("updating mobile location", () => {
test("updates the page model after providing the mobile location", async () => {
// Arrange
const { wrapper } = setupMocks({});
await wrapper.setData({
shopProviderData: {
mobileProviderNumber: "001820",
},
selectedAppointmentType: "Mobile",
providerData: { mobileProviderNumber: "01820" },
isGlassServiceableMobile: true,
});
const mobileLocationQuestionsComponent = wrapper.findComponent({
ref: "mobileLocationQuestions",
});
mobileLocationQuestionsComponent.resetComponent = jest.fn();
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
serviceZipCodeComponent.resetMobileFeePart = jest.fn();
wrapper.vm.forwardButtonAction = jest.fn();
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Sulgrave Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "New Albany",
state: "OH",
zipCode: "43054",
},
isVehicleProtected: true,
isMobileSelected: true,
};
// Act
// Trigger the event
await mobileLocationQuestionsComponent.vm.$emit(
"setMobileLocation",
newMobileLocationQuestions
);
await wrapper.vm.$nextTick(); // Wait for DOM updates
// Assert
expect(wrapper.vm.mobileLocationQuestions).toStrictEqual(newMobileLocationQuestions);
});
test("resets service zip code when mobile location is updated", async () => {
// Arrange
const { wrapper } = setupMocks({});
await wrapper.setData({
shopProviderData: {
mobileProviderNumber: "001820",
},
selectedAppointmentType: "Mobile",
providerData: { mobileProviderNumber: "01820" },
isGlassServiceableMobile: true,
});
const mobileLocationQuestionsComponent = wrapper.findComponent({
ref: "mobileLocationQuestions",
});
mobileLocationQuestionsComponent.resetComponent = jest.fn();
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
serviceZipCodeComponent.resetMobileFeePart = jest.fn();
wrapper.vm.forwardButtonAction = jest.fn();
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "",
apartmentNumberOrBusinessName: "",
city: "",
state: "",
zipCode: "",
},
isVehicleProtected: null,
isMobileSelectedAndModalClosed: false,
mobileFeePart: null,
};
wrapper.vm.mobileLocationQuestions = mobileLocationQuestions;
const newMobileLocationQuestions = {
addressQuestions: {
streetAddress: "5555 Rustic Dr",
apartmentNumberOrBusinessName: "Apt 1",
city: "Westerville",
state: "OH",
zipCode: "43081",
},
isVehicleProtected: true,
isMobileSelectedAndModalClosed: true,
mobileFeePart: null,
};
wrapper.vm.serviceZipCodeQuestion = {
zipCode: "61606",
state: "IL",
zipCodeCtu: "01526",
};
const newServiceZipCodeInfo = {
state: "OH",
zipCode: "43081",
zipCodeCtu: "01526",
};
wrapper.vm.closeModalAction = jest.fn();
// Act
// Trigger the event
await mobileLocationQuestionsComponent.vm.$emit(
"setMobileLocation",
newMobileLocationQuestions
);
await wrapper.vm.$nextTick(); // Wait for DOM updates
// Assert
expect(wrapper.vm.serviceZipCodeQuestion).toStrictEqual(newServiceZipCodeInfo);
});
test("does not reset appointment type selection when service zip code is updated by mobile location modal when Mobile is selected", async () => {
// Arrange
const { wrapper } = setupMocks({});
await wrapper.setData({
shopProviderData: {
mobileProviderNumber: "001820",
},
selectedAppointmentType: "Mobile",
providerData: { mobileProviderNumber: "01820" },
});
const mobileLocationQuestionsComponent = wrapper.findComponent({
ref: "mobileLocationQuestions",
});
mobileLocationQuestionsComponent.resetComponent = jest.fn();
wrapper.vm.forwardButtonAction = jest.fn();
wrapper.vm.isServiceableMobile = true;
const serviceZipCodeComponent = wrapper.findComponent({
ref: "serviceZipCodeQuestion",
});
serviceZipCodeComponent.resetMobileFeePart = jest.fn();
const mobileLocationQuestions = {
addressQuestions: {
streetAddress: "555 Some Street",
apartmentNumberOrBusinessName: "",
city: "Westerville",
state: "OH",
zipCode: "43081",
},
isVehicleProtected: "YesAnswer",
isMobileSelectedAndModalClosed: true,
};
wrapper.vm.closeModalAction = jest.fn();
// Act
mobileLocationQuestionsComponent.vm.$emit("setMobileLocation", mobileLocationQuestions);
// Assert
expect(wrapper.vm.selectedAppointmentType).toStrictEqual("Mobile");
});
});
describe("serviceability logic", () => {
describe("should be logical AND when recalibration is defined.", () => {
test("T & T => T", async () => {
@ -622,7 +393,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -649,7 +426,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -676,7 +459,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -703,7 +492,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -730,7 +525,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -758,7 +559,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -786,7 +593,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -814,7 +627,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -842,7 +661,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -870,7 +695,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -898,7 +729,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -926,7 +763,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -954,7 +797,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -982,7 +831,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1010,7 +865,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1041,7 +902,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1068,7 +935,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1095,7 +968,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1123,7 +1002,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1151,7 +1036,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1179,7 +1070,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1209,7 +1106,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1237,7 +1140,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1265,7 +1174,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1293,7 +1208,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1321,7 +1242,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1349,7 +1276,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1377,7 +1310,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1407,7 +1346,13 @@ describe("service-location.vue", () => {
const { wrapper } = setupMocks({});
wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
//wrapper.vm.$refs.shopQuestion.initializeComponent = jest.fn();
const shopQuestionPopupComponent = wrapper.findComponent({
ref: "shopQuestionPopup",
});
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
// Act
await serviceLocation.beforeRouteEnter.call(
@ -1434,8 +1379,12 @@ function setupMocks({ mountOptionsMockData = {} }) {
const mountOptions = getMountOptions(mountOptionsMockData);
const wrapper = shallowMount(serviceLocation, mountOptions);
wrapper.vm.setCmsContent = baseMixin.methods.setCmsContent;
wrapper.vm.$refs.mobileLocationQuestions.isMobileAddressComplete = jest.fn();
wrapper.vm.$refs.mobileLocationQuestions.resetAlerts = jest.fn();
wrapper.vm.$refs.mobileLocationQuestions.openModal = jest.fn();
if (!wrapper.vm.shopProviderData) {
wrapper.vm.shopProviderData = { shopProviders: [] };
}
const shopQuestionPopupComponent = wrapper.findComponent({ ref: "shopQuestionPopup" });
if (shopQuestionPopupComponent.exists()) {
shopQuestionPopupComponent.vm.initializeComponent = jest.fn();
}
return { wrapper, apiPromise };
}

View file

@ -89,32 +89,45 @@
</div>
<div class="row justify-content-center">
<div class="col-md-6 col-xl-4">
<mobileLocationModalQuestions
customComponentId="mobileLocationQuestions"
v-show="selectedAppointmentType === 'Mobile'"
v-model="mobileLocationQuestions"
:mobileFeePart="mobileFeePart"
:mobileFeeApplies="mobileFeeApplies"
@updated-mobile-fee-part="setMobileFeePart"
@updated-recycle-fee-part="setRecycleFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase"
@updated-mobile-ctu="setCtuForMobile"
validationRules="mobile-location-required"
ref="mobileLocationQuestions"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget"
@setMobileLocation="setMobileLocationQuestions"
@set-mobile-location-invalid="setMobileLocationInValid" />
<shopQuestion
ref="shopQuestion"
v-show="isShopQuestionDisplayed"
v-model="selectedProvider"
<button-question
v-if="selectedAppointmentType == appointmentTypeStrings.IN_SHOP"
class="shop-question-button"
ref="buttonQuestion"
:answers="selectedShopAnswer"
:modelValue="selectedProvider?.providerNumber"
:isMultiSelect="false"
:readonly="true"
:questionText="questionText"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
groupName="chooseShop"
textPosition="text-start" />
<div class="text-center">
<textBlock
v-if="mobileFeeApplies && isMobileSelected"
:customText="mobileFeeText"
cmsWidgetName="MobileFeeDisclaimerWidget"
typeStyle="caption"
class="ps-4 pe-4 mt-4" />
</div>
<shopQuestionPopup
modalWidgetName="ShopQuestionPopupWidget"
:selectedAppointmentType="selectedAppointmentType"
:modelValue="{
zipCode: zipCode,
state: state,
}"
:preSelectedProviderNumber="selectedProvider?.providerNumber"
:shopProviderData="shopProviderData"
:isServiceableInshop="isServiceableInshop"
@updated-serviceability="setServiceabilityDetails"
@shop-selected="onShopSelected"
@update:modelValue="onShopModelUpdated"
@updated-zipcode-ctu="setZipcodeCtu"
@update-shop-provider-data="shopProviderData = $event"
:zipCode="zipCode"
:isDisplayed="isShopQuestionDisplayed"
cmsWidgetName="ShopQuestionWidget" />
cmsWidgetName="YourSafeliteShopWidget" />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" />
@ -134,9 +147,12 @@
// Components
import alert from "@/ux-components/alert/alert";
import serviceZipModalQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-modal-question";
import mobileLocationModalQuestions from "@/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions";
import appointmentTypeQuestion from "@/layouts/service-location/appointment-type-question/appointment-type-question";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
import shopQuestionPopup from "@/layouts/service-location/shop-question/shop-question-popup";
import buttonQuestion from "@/digital-components/button-question/button-question.vue";
import shopListButton from "@/layouts/service-location/shop-question/shop-list-button/shop-list-button";
import { getAvailabilityRating } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import navbar from "@/fmg-components/nav-bar/nav-bar";
@ -144,6 +160,7 @@ import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-heade
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import { Form } from "vee-validate";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
import textBlock from "@/digital-components/text-block/text-block";
// Supporting files
import baseMixin from "@/mixins/base-mixin.js";
@ -222,6 +239,7 @@ export default {
shopProviderData: null,
navigatingForward: false,
isMobileAddressValid: true,
shopListButton: shopListButton,
};
},
async beforeRouteEnter(to, from, next) {
@ -301,20 +319,8 @@ export default {
this.$nextTick();
},
},
mobileLocationQuestions: {
get: function () {
return {
addressQuestions: {
streetAddress: this.streetAddress,
apartmentNumberOrBusinessName: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode,
},
isVehicleProtected: this.isVehicleProtected,
isMobileSelected: this.selectedAppointmentType == AppointmentTypeStrings.MOBILE,
};
},
isMobileSelected() {
return this.selectedAppointmentType == AppointmentTypeStrings.MOBILE;
},
isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) {
@ -357,6 +363,20 @@ export default {
return this.mobileFeeHasPrice;
}
},
mobileFeeText() {
const cmsContentText = this.getCmsContent("MobileFeeDisclaimerWidget", "Text");
return cmsContentText.replaceAll("{custom:mobileFee}", this.mobileFee);
},
mobileFee() {
if (!this.mobileFeePart) {
return 0;
}
return (
this.mobileFeePart.laborAmount +
this.mobileFeePart.sellingPrice +
this.mobileFeePart.kitPrice
);
},
showMobileFreeAlert() {
if (this.isServiceableMobile && !this.isInsurance && !this.mobileFeeHasPrice) {
return true;
@ -429,6 +449,57 @@ export default {
isForwardActionDisabled() {
return this.displayNoShopsAlert || !this.isMobileAddressValid;
},
additionalButtonData() {
const startDate = new Date();
const endDate = new Date();
endDate.setDate(startDate.getDate() + 6);
const formattedStartDate = startDate.toISOString().split("T")[0];
const formattedEndDate = endDate.toISOString().split("T")[0];
return {
availabilityRatingCallback: getAvailabilityRating,
startDate: formattedStartDate,
endDate: formattedEndDate,
shopAppointmentType: this.selectedAppointmentType,
};
},
selectedShopAnswer() {
const toTitleCase = (str) => {
if (!str) return "";
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
};
if (this.selectedProvider && this.selectedProvider.address) {
const provider = this.shopProviderData?.shopProviders?.find(
(p) => p.providerNumber === this.selectedProvider?.providerNumber
);
const streetAddress = toTitleCase(this.selectedProvider.address.streetAddress);
const city = toTitleCase(this.selectedProvider.address.city);
const state = this.selectedProvider.address.state;
const zipCode = this.selectedProvider.address.zipCode;
const distanceInMiles = provider ? Math.round(provider.distanceInMiles * 2) / 2 : 0;
return [
{
buttonLabel: `${city}`,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: `${streetAddress}, ${city}, ${state} ${zipCode}`,
additionalButtonData: this.additionalButtonData,
value: this.selectedProvider.providerNumber,
},
];
}
return [];
},
questionText() {
return this.getCmsContent("YourSafeliteShopWidget", "QuestionText");
},
appointmentTypeStrings() {
return AppointmentTypeStrings;
},
},
methods: {
arePagePrerequisitesValid() {
@ -516,32 +587,6 @@ export default {
setRecycleFeePart(recycleFeePart) {
this.recycleFeePart = recycleFeePart;
},
//creating async function as the computed property can not directly handle asynchronous operations or promises.
async setMobileLocationQuestions(newValue) {
var newZipCode = newValue.addressQuestions.zipCode;
if (newZipCode !== this.zipCode) {
await getShopProviderData(newZipCode).then((result) => {
if (this.isServiceableMobile) {
this.shopProviderData = result.data;
this.selectedProvider = new Provider(
this.shopProviderData.mobileProviderNumber
);
}
});
}
if (this.isServiceableMobile) {
this.setMobileLocation(newValue);
this.setMobileLocationInValid(false);
} else {
await getZipCodeData(newZipCode).then((zipCodeData) => {
this.serviceZipCodeQuestion = {
state: zipCodeData.state,
zipCode: newZipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
};
});
}
},
getCarIdfromStore() {
return store.getters.vehicle.carId;
},
@ -590,15 +635,6 @@ export default {
this.isRecalibrationServiceableMobile =
serviceabilityDetails.isRecalibrationServiceableMobile;
},
setMobileLocation(mobileLocation) {
this.streetAddress = mobileLocation.addressQuestions.streetAddress;
this.apartmentNumberOrBusinessName =
mobileLocation.addressQuestions.apartmentNumberOrBusinessName;
this.city = mobileLocation.addressQuestions.city;
this.state = mobileLocation.addressQuestions.state;
this.zipCode = mobileLocation.addressQuestions.zipCode;
this.isVehicleProtected = mobileLocation.isVehicleProtected;
},
async reloadShopData(zipCode) {
await this.$refs.shopQuestion.reloadShopData(zipCode);
},
@ -712,8 +748,6 @@ export default {
this.selectedProvider.address.zipCode = null;
this.selectedProvider.address.zipCodeCtu = null;
} else {
// if we have a provider.zipCodeCtu that's different than the servicelocation.zipCodeCtu then they
// may have selected a shop in a different ctu. change the servicelocation zip/ctu if different
if (this.zipCodeCtu != this.selectedProvider.address.zipCodeCtu) {
ctu = this.selectedProvider.address.zipCodeCtu;
}
@ -755,13 +789,88 @@ export default {
this.updateAndSaveSupportingItems();
this.updateAndSaveIsMSRFeeApplicable();
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
//mocked schedule and should be clear when implement schedule page.
this.dispatchStoreAction(
this.storeActions.SAVE_SCHEDULE,
this.getTimeSlotInfo(),
false
);
if (this.selectedAppointmentType == AppointmentTypeStrings.MOBILE) {
//navigate to mobile details page
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE,
this.pageName
);
} else {
this.$router.navigateWithSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.pageName
);
}
},
setMobileLocationInValid(isMobileLocationInValid) {
this.isMobileAddressValid = !isMobileLocationInValid;
getTimeSlotInfo() {
// Get the current date
const currentDate = new Date();
// Add 10 days to the current date
currentDate.setDate(currentDate.getDate() + 10);
// Format the date as yyyy-mm-dd
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
const day = String(currentDate.getDate()).padStart(2, "0");
const formattedDate = `${year}-${month}-${day}`;
return {
date: formattedDate,
endTime: "12:00",
jobMaxMinutes: "180",
jobMinMinutes: "120",
routeCode: "3357I-03357-M-I*20988*AM",
startTime: "08:00",
};
},
onShopSelected(providerObject) {
const current = this.shopProviderData?.shopProviders?.find(
(p) => p.providerNumber === this.selectedProvider?.providerNumber
);
if (current) {
this.selectedProvider = current;
}
let provider = this.shopProviderData?.shopProviders?.find(
(p) => p.providerNumber === providerObject?.providerNumber
);
this.selectedProvider = provider;
if (providerObject && providerObject.address) {
this.zipCode = providerObject.searchedZip;
this.state = providerObject.searchedState;
}
},
setZipcodeCtu(zipcodeCtu) {
this.zipCodeCtu = zipcodeCtu;
},
onShopModelUpdated(newModel) {
if (newModel.zipCode) {
this.zipCode = newModel.zipCode;
}
if (newModel.zipCodeCtu) {
this.zipCodeCtu = newModel.zipCodeCtu;
}
if (newModel.state) {
this.state = newModel.state;
}
if (this.shopProviderData && newModel.selectedProviderNumber) {
const provider = this.shopProviderData.shopProviders.find(
(p) => p.providerNumber === newModel.selectedProviderNumber
);
if (provider) {
this.selectedProvider = provider;
}
}
},
},
watch: {
@ -775,7 +884,7 @@ export default {
this.shopProviderData.mobileProviderNumber
);
} else {
this.selectedProvider = new Provider();
this.isMobileAddressValid = true;
}
});
}
@ -787,18 +896,8 @@ export default {
this.selectedProvider = new Provider(
this.shopProviderData.mobileProviderNumber
);
if (this.zipCode == this.getServiceZipCodeFromStore()) {
//restore mobile address from store in case user make changes to address but not commit it.
this.streetAddress = this.getServiceAddressFromStore();
this.apartmentNumberOrBusinessName = this.getServiceAddress2FromStore();
this.city = this.getServiceCityFromStore();
this.isVehicleProtected = this.getIsVehicleProtectedFromStore();
} else {
this.resetMobileLocation();
}
this.$refs.mobileLocationQuestions.resetAlerts();
} else {
this.selectedProvider = new Provider();
this.selectedProvider = this.shopProviderData.shopProviders[0];
}
},
},
@ -807,14 +906,15 @@ export default {
alert,
serviceZipModalQuestion,
appointmentTypeQuestion,
mobileLocationModalQuestions,
funnelHeader,
navbar,
funnelSubHeader,
Form,
loadingModal,
contentGroupModal,
shopQuestion,
shopQuestionPopup,
buttonQuestion,
textBlock,
},
};
</script>
@ -839,4 +939,8 @@ export default {
color: $black;
}
}
.shop-question-button .list-button-content {
background: $blue-100 !important;
box-shadow: 0 0 0 1px $primary !important;
}
</style>

View file

@ -7,9 +7,11 @@
questionAlignment="center"
cornerStyle="rounded"
mask="#####"
:includeSearchIcon="includeSearchIcon"
@search-icon-click="$emit('search-icon-click')"
:displayQuestionText="false"
isRequired
validationRules="zip-required|zip-format" />
:isRequired="isRequired"
:validationRules="computedValidationRules" />
</template>
<script>
@ -31,6 +33,14 @@ export default {
serviceZipCode: String,
},
cmsWidgetName: String,
isRequired: {
type: Boolean,
default: true,
},
includeSearchIcon: {
type: Boolean,
default: false,
},
},
computed: {
value: {
@ -41,6 +51,9 @@ export default {
this.$emit("update:modelValue", newValue);
},
},
computedValidationRules() {
return this.isRequired ? "zip-required|zip-format" : "zip-format";
},
},
components: {
textboxQuestion,

View file

@ -0,0 +1,751 @@
<template>
<transition name="fade" mode="out-in">
<div v-if="isDisplayed" class="shop-question" aria-live="polite">
<div class="modal-link">
<text-link
linkType="text"
:text="shopQuestionLinkText"
href="#!"
@click-event="openModal"></text-link>
</div>
</div>
</transition>
<modal
ref="shopQuestionModal"
:headerText="modalHeaderText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:footerButtonDisabled="
displayInvalidZipAlert || displayNoShopsAlert || !selectedProviderNumber
"
@footer-button-event="setZipCodeAndShop">
<serviceZipQuestion
ref="serviceZipQuestion"
class="shop-question-zipcode"
customInputId="serviceZipCode"
v-model="internalModel.zipCode"
v-on="{ 'textboxQuestionEvent.inputIdAssigned': onInputIdAssigned }"
cmsWidgetName="ServiceZipQuestionWidget"
@search-icon-click="onSearchZip"
@keydown.enter="onSearchZip"
includeSearchIcon
:isRequired="false"
:validationRules="''" />
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"
class="mb-4"
cmsWidgetName="AlertInvalidZipWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
<alert
ref="alertNoShops"
class="my-5"
cmsWidgetName="AlertNoShopsWidget"
v-if="displayNoShopsAlert"
alertClass="alert-warning" />
<div v-if="!displayInvalidZipAlert">
<buttonQuestion
ref="buttonQuestion"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
class="radioQuestion"
:questionText="questionText"
:answers="answers"
groupName="chooseShop"
textPosition="text-start"
v-model="selectedProviderNumber"
isRequired
:validationRules="this.selectedProviderNumber ? '' : 'option-required'" />
<div class="show-more-shops-link">
<textLink
v-if="displaySeeMoreLocationsLink"
ref="showMoreShopsLink"
id="showMoreShopsId2"
cmsWidgetName="ShowMoreShopsLinkWidget"
linkType="text"
:text="showMoreShopsLinkText"
href="#!"
@click-event="getNextShopsFromList(3)"
:aria-label="showMoreShopsLinkText" />
</div>
</div>
</modal>
</template>
<script>
// Components
import modal from "@/digital-components/modal/modal";
import textLink from "@/ux-components/text-link/text-link.vue";
import serviceZipQuestion from "@/layouts/service-location/service-zip-modal-question/service-zip-question/service-zip-question.vue";
import buttonQuestion from "@/digital-components/button-question/button-question.vue";
import alert from "@/ux-components/alert/alert.vue";
import shopListButton from "./shop-list-button/shop-list-button";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
// Supporting files
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
import { errorMessages } from "@/constants/error-messages";
import baseMixin from "@/mixins/base-mixin.js";
import { nextTick } from "vue";
import { regex } from "@/helpers/validation-rules";
import { getAvailabilityRating } from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
import { Provider } from "@/layouts/service-location/classes/provider";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import {
getPricedMobileFeePart,
getPricedRecycleFeePart,
getServiceabilityDetails,
getBillToAccountNumber,
getClosestApplicableShops,
getShopProviderData,
getZipCodeData,
} from "@/layouts/service-location/helpers/service-location-helper/service-location-helper";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
export default {
name: "shop-question-popup",
mixins: [baseMixin],
data() {
return {
isShopQuestionDisplayed: true,
internalModel: this.copyModel(this.modelValue),
answers: [],
shopListButton: shopListButton,
shopIndex: 0,
displaySeeMoreLocationsLink: false,
serviceZipCodeTextInputId: "",
localShopProviderData: this.shopProviderData,
selectedProviderNumber: null,
isLoadingShops: false,
lastSearchedZip: "",
lastSuccessfulZip: "",
hasLoadedShopData: false,
displayInvalidZipAlert: false,
areShopsAvialable: true,
displayNoShopsAlert: false,
};
},
props: {
appointmentType: String,
shopProviderData: Object,
modelValue: {
type: Object,
default: () => ({
state: "",
zipCode: "",
}),
},
isDisplayed: Boolean,
modelWidgetName: {
type: String,
required: true,
},
isServiceableInshop: {
type: Boolean,
default: false,
},
zipCode: {
type: String,
default: "",
},
preSelectedProviderNumber: {
type: String,
default: null,
},
},
computed: {
shopQuestionLinkText() {
return this.getCmsContent("ChangeMyLocationLinkWidget", "Text");
},
modalName() {
return this.modelWidgetName;
},
modalHeaderText() {
return this.getCmsContent("ShopQuestionWidget", "QuestionText");
},
selectShopText() {
return this.getCmsContent("YourSafeliteShopWidget", "QuestionText");
},
modal() {
return this.$refs.shopQuestionModal;
},
shopProviders() {
return this.localShopProviderData?.shopProviders ?? [];
},
modalFooterText() {
return this.getCmsContent("SaveLocationWidget", "Text");
},
selectedValue: {
get: function () {
return this.modelValue;
},
set: function (newValue) {},
},
additionalButtonData() {
const startDate = new Date();
const endDate = new Date();
endDate.setDate(startDate.getDate() + 6);
const formattedStartDate = startDate.toISOString().split("T")[0];
const formattedEndDate = endDate.toISOString().split("T")[0];
return {
availabilityRatingCallback: getAvailabilityRating,
startDate: formattedStartDate,
endDate: formattedEndDate,
shopAppointmentType: this.appointmentType,
};
},
showMoreShopsLinkText() {
return this.getCmsContent("ShowMoreShopsLinkWidget", "Text");
},
},
methods: {
openModal(event) {
if (event) event.preventDefault();
if (
!this.shopProviderData ||
!this.shopProviderData.shopProviders ||
!this.shopProviderData.shopProviders.length
) {
// Optionally show a loading message or disable the link
return;
}
this.$refs.shopQuestionModal.openModal();
},
getSelectedProviderObject(providerNumber) {
const provider =
this.shopProviders?.find((provider) => provider.providerNumber == providerNumber) ??
new Provider();
return provider;
},
async getNextShopsFromList(numberToGet = 3) {
const logFirstShopsDisplayed = this.shopIndex == 0;
const shopIterator = (array, n) => {
const l = array.length;
return () => {
const end = this.shopIndex + n;
const part = array.slice(this.shopIndex, end);
this.shopIndex = end < l ? end : this.shopProviders.length;
return part;
};
};
const toTitleCase = (str) => {
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
};
const nextShop = shopIterator(this.shopProviders, numberToGet);
// Map API result data
const mappedData = nextShop().map((shopProvider) => {
const streetAddress = toTitleCase(shopProvider.address.streetAddress);
const city = toTitleCase(shopProvider.address.city);
const state = shopProvider.address.state;
const zipCode = shopProvider.address.zipCode;
const distanceInMiles = Math.round(shopProvider.distanceInMiles * 2) / 2;
return {
buttonLabel: city,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: `${streetAddress}, ${city}, ${state} ${zipCode}`,
additionalButtonData: this.additionalButtonData,
value: shopProvider.providerNumber,
};
});
var gaAction = this.GaActions.MORE_LOCATIONS_CLICKED;
if (logFirstShopsDisplayed) {
gaAction = this.GaActions.SHOPS_FIRST_DISPLAYED;
}
if (
this.appointmentType === AppointmentTypeStrings.IN_SHOP ||
this.appointmentType === AppointmentTypeStrings.DROP_OFF ||
this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF
) {
var shops = mappedData.map((shop) => {
if (shop.value.length > 5 && shop.value.startsWith("00")) {
return shop.value.substring(1);
} else {
return shop.value;
}
});
var joinedShops = shops.join(",");
if (!joinedShops) {
joinedShops = "no-shops";
}
this.pushEventToGA(this.GaCategories.SERVICE_LOCATION, gaAction, joinedShops, true);
}
if (this.answers.length === 0) {
this.answers = mappedData;
} else {
mappedData.forEach((shop) => {
this.answers.push(shop);
});
}
await nextTick();
if (this.shopIndex == this.shopProviders.length) {
this.displaySeeMoreLocationsLink = false;
} else {
this.displaySeeMoreLocationsLink = true;
}
await nextTick();
this.scrollToPageBottom();
},
async handleUpdate(selectedShopIndex = null) {
this.resetAnswers();
if (selectedShopIndex >= 3) {
await this.getNextShopsFromList(selectedShopIndex + 1);
} else {
await this.getNextShopsFromList();
await nextTick();
}
},
copyModel(modelToCopy) {
return {
state: modelToCopy.state,
zipCode: modelToCopy.zipCode,
};
},
resetAnswers() {
this.answers = [];
this.shopIndex = 0;
},
onModalOpened() {
const preSelectedIndex = this.shopProviders.findIndex(
(provider) => provider.providerNumber == this.preSelectedProviderNumber
);
if (preSelectedIndex > -1) {
this.shopIndex = Math.ceil((preSelectedIndex + 1) / 3) * 3;
} else {
this.shopIndex = 3;
}
this.internalModel = this.copyModel(this.modelValue);
this.internalModel.zipCode = this.zipCode;
this.localShopProviderData = this.shopProviderData;
this.answers = [];
this.onSearchZip();
this.focusOnZipInput();
this.selectedProviderNumber = this.preSelectedProviderNumber;
if (
(!this.selectedProviderNumber ||
!this.shopProviders.some(
(p) => p.providerNumber == this.selectedProviderNumber
)) &&
this.shopProviders.length > 0
) {
this.selectedProviderNumber = this.shopProviders[0].providerNumber;
}
},
closeModal() {
this.modal.closeModal();
},
onModalClosed() {
this.internalModel = this.copyModel(this.modelValue);
this.resetsOnZipInput();
},
resetsOnZipInput() {
this.resetAlerts();
},
async onSearchZip(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
this.resetsOnZipInput();
this.displayNoShopsAlert = false;
const zip = this.internalModel.zipCode;
const zipCodeData = await this.getZipCodeData(
this.internalModel.zipCode,
"service-location"
);
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
//this.selectedProviderNumber = null;
this.focusOnZipInput();
this.resetModalButtonStyle();
} else {
if (this.isVehicleHeavyTruck) {
// check the location endpoint to verify this zip can service a heavy truck
const closestShops = await getClosestApplicableShops(
this.internalModel.zipCode,
this.carId,
"service-location"
);
if (!closestShops || closestShops.providers?.length === 0) {
this.displayNoServiceAlert = true;
this.focusOnZipInput();
this.resetModalButtonStyle();
return null;
}
} else {
getShopProviderData(zip).then(async (result) => {
if (this.displayNoShopsAlert === true) {
this.displayNoShopsAlert = false;
}
this.localShopProviderData = result.data;
this.updateShopsForZip(this.internalModel.zipCode);
if (this.appointmentType === AppointmentTypeStrings.MOBILE) {
this.selectedProvider = new Provider(
this.shopProviderData.mobileProviderNumber
);
} else {
this.selectedProvider = new Provider();
}
});
}
}
},
async updateShopsForZip(zipCode) {
if (!this.appointmentType) {
this.answers = [];
this.isLoadingShops = false;
return;
}
this.lastSearchedZip = zipCode;
if (!zipCode) {
this.isLoadingShops = false;
this.answers = [];
return;
}
this.isLoadingShops = true;
if (!zipCode || !this.shopProviders.length) {
this.answers = [];
this.displayNoShopsAlert = true;
this.displaySeeMoreLocationsLink = false;
this.isLoadingShops = false;
return;
}
const selectedIndex = this.shopProviders.findIndex(
(provider) => provider.providerNumber == this.selectedProviderNumber
);
// If not found, only show the first 3 shops
if (selectedIndex === -1) {
this.shopIndex = 3;
}
const sortedShops = this.getShopsByZip(zipCode, this.shopProviders, this.shopIndex);
const toTitleCase = (str) => {
return str.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
});
};
const mappedData = sortedShops.map((shopProvider) => {
const streetAddress = toTitleCase(shopProvider.address.streetAddress);
const city = toTitleCase(shopProvider.address.city);
const state = shopProvider.address.state;
const zipCode = shopProvider.address.zipCode;
const distanceInMiles = Math.round(shopProvider.distanceInMiles * 2) / 2;
const startDate = new Date();
const endDate = new Date();
endDate.setDate(startDate.getDate() + 6);
const formattedStartDate = startDate.toISOString().split("T")[0];
const formattedEndDate = endDate.toISOString().split("T")[0];
return {
buttonLabel: city,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: `${streetAddress}, ${city}, ${state} ${zipCode}`,
additionalButtonData: {
availabilityRatingCallback: getAvailabilityRating,
startDate: formattedStartDate,
endDate: formattedEndDate,
shopAppointmentType: this.appointmentType,
},
value: shopProvider.providerNumber,
};
});
this.answers = mappedData;
if (
this.selectedProviderNumber &&
!this.answers.some((a) => String(a.value) === String(this.selectedProviderNumber))
) {
this.selectedProviderNumber = null;
}
this.shopIndex = mappedData.length;
this.displaySeeMoreLocationsLink = this.shopIndex < this.shopProviders.length;
if (mappedData.length > 0) {
this.lastSuccessfulZip = zipCode;
}
this.isLoadingShops = false;
this.displayNoShopsAlert = this.answers.length === 0;
},
resetModalButtonStyle() {
this.modal.resetButtonStyle();
},
onInputIdAssigned(inputId) {
this.serviceZipCodeTextInputId = inputId;
},
focusOnZipInput() {
const input = document.getElementById(this.serviceZipCodeTextInputId);
input?.focus();
},
resetAlerts() {
this.displayInvalidZipAlert = false;
this.displayNoServiceAlert = false;
this.displayNoShopsAlert = false;
},
async setZipCodeAndShop() {
if (
this.displayInvalidZipAlert ||
this.displayNoShopsAlert ||
!this.selectedProviderNumber
) {
return;
}
const zipToEmit = this.lastSearchedZip;
const selectedProvider = this.shopProviders.find(
(provider) => provider.providerNumber == this.selectedProviderNumber
);
this.resetAlerts();
const zipCodeData = await this.getZipCodeData(zipToEmit, "service-location");
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
this.focusOnZipInput();
this.resetModalButtonStyle();
} else {
if (this.isVehicleHeavyTruck) {
// check the location endpoint to verify this zip can service a heavy truck
const closestShops = await getClosestApplicableShops(
selectedProvider.address.zipCode,
this.carId,
"service-location"
);
if (!closestShops || closestShops.providers?.length === 0) {
this.displayNoServiceAlert = true;
this.focusOnZipInput();
this.resetModalButtonStyle();
return null;
}
}
selectedProvider.address.zipCodeCtu = zipCodeData.zipCodeCtu;
// retrieve mobile fee part
const serviceZipCode = selectedProvider.address.zipCode;
const mobileFeePart = await getPricedMobileFeePart(
serviceZipCode,
"service-location"
);
// retrieve recycle fee part
const recycleFeePart = await getPricedRecycleFeePart(
serviceZipCode,
"service-location"
);
// retrieve serviceability details
const serviceabilityDetails = await getServiceabilityDetails(
serviceZipCode,
null,
"service-location"
);
const billToAccountNumber = await getBillToAccountNumber(
selectedProvider.address.zipCodeCtu
);
// update content related to service zip code
this.$emit("updated-mobile-fee-part", mobileFeePart);
this.$emit("updated-recycle-fee-part", recycleFeePart);
this.$emit("updated-serviceability", serviceabilityDetails.data);
this.$emit("updated-contains-military-base", zipCodeData.containsMilitaryBase);
this.$emit("updated-bill-to-account-number", billToAccountNumber);
this.$emit("updated-zipcode-ctu", selectedProvider.address.zipCodeCtu);
this.$emit("update:modelValue", this.selectedValue);
this.$emit("update-shop-provider-data", this.localShopProviderData);
this.internalModel.state = zipCodeData.state;
this.$emit("shop-selected", {
providerNumber: selectedProvider?.providerNumber,
address: selectedProvider?.address,
searchedZip: zipToEmit,
searchedState: this.internalModel.state,
});
this.closeModal();
}
},
getShopsByZip(zipCode, shops, numberToGet = 3) {
return shops
.filter(
(shop) => shop.address && shop.address.zipCode && shop.address.zipCode !== ""
)
.sort((a, b) => a.distanceInMiles - b.distanceInMiles)
.slice(0, numberToGet);
},
},
watch: {
shopProviderData: {
immediate: true,
handler(newVal) {
this.localShopProviderData = newVal;
this.hasLoadedShopData = true;
},
},
},
components: {
modal,
textLink,
serviceZipQuestion,
buttonQuestion,
alert,
},
};
</script>
<style lang="scss">
.shop-question {
margin-top: 1rem;
text-align: center;
a {
@include responsive-font-size-md(0.875rem, 1rem);
}
.button-question {
.question-text {
margin-top: 0.5rem;
}
}
}
.drop-off-alert {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A");
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
padding: 0.5rem 0.5rem 0.5rem 1.5rem;
gap: 0.25rem;
.alert-heading {
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
.modal-link {
text-align: center;
}
.modal.modal-component .modal-dialog .modal-content .modal-body .textbox-question {
padding: 0;
}
.shop-question-zipcode {
display: flex;
flex-direction: row;
align-items: stretch;
width: 100%;
height: 48px;
border-radius: 48px;
padding-left: 16px;
border: 1px solid #ccc;
background: #fff;
margin-bottom: 20px;
@include media-breakpoint-up(md) {
height: auto;
padding-left: 0;
}
.input-wrapper.has-search-icon {
display: flex;
align-items: stretch;
flex: 1 1 0;
height: 100%;
margin-bottom: 0;
input,
.form-control {
width: 100%;
height: 48px;
min-height: 48px;
max-height: 48px;
border: none;
padding: 0 1.5rem 0 1.5rem;
outline: none;
font-size: 1rem;
background: transparent;
box-sizing: border-box;
border-radius: 2.5rem 0 0 2.5rem;
}
}
.search-icon-button {
display: flex;
align-items: center;
justify-content: center;
background-color: #e5f1fa !important;
width: 2.5rem;
height: 48px;
border: none;
max-height: 48px;
border-radius: 0 2.5rem 2.5rem 0;
cursor: pointer;
}
}
.show-more-shops-link a {
text-align: center;
display: block;
margin-left: auto;
margin-right: auto;
}
.shop-question-zipcode .search-icon-button {
background-color: #e5f1fa !important;
border-radius: 0 2.5rem 2.5rem 0;
}
.shop-question-zipcode .search-icon-button:hover,
.shop-question-zipcode .search-icon-button:focus {
border: 1px solid $gray-500;
box-shadow: 0 0 0 4px $blue-300;
background-color: #e5f1fa;
outline: none;
}
.shop-question-zipcode .input-wrapper.has-search-icon .form-control {
border-radius: 2.5rem;
padding-left: 1.5rem;
}
.shop-question-zipcode .form-test-error {
display: block;
width: 100%;
margin-top: 0.25rem;
margin-left: 0;
color: #d32f2f; // or your error color
position: static !important;
font-size: 0.875rem;
clear: both;
float: none;
display: block;
width: 100%;
}
</style>

View file

@ -277,23 +277,24 @@ export default {
margin-top: 0.5rem;
}
}
}
.drop-off-alert {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A");
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
padding: 0.5rem 0.5rem 0.5rem 1.5rem;
gap: 0.25rem;
.drop-off-alert {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A");
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
padding: 0.5rem 0.5rem 0.5rem 1.5rem;
gap: 0.25rem;
background-color: #e4f1f7;
.alert-heading {
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
.alert-heading {
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
}
</style>

View file

@ -12,7 +12,8 @@ import {
setSessionKeyIfUnset,
} from "@/helpers/heritage-integration/cookie-helper";
import { queryStrings } from "@/constants/query-strings";
import { experimentSettings } from "@/constants/experiments";
import { experimentSettings, experimentUniverses } from "@/constants/experiments";
import experimentMixin from "@/mixins/experiment-mixin";
import {
analyticsPageEvents,
GaCategories,
@ -128,6 +129,60 @@ export default {
}
},
async logDigitalConsumer() {
const currentPageName = getPageNameFromRouter();
const universes = store.getters.applicationUser.experiments;
let hasDynamoLogging = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.DYNAMO_LOGGING,
"true",
universes
);
if (!hasDynamoLogging) {
return;
}
const variationNames = universes
.filter((item) => item.universeName === experimentUniverses.CONCEPT_FUNNEL)
.map((item) => item.variationName)
.filter(Boolean); // removes undefined/null
const conceptVariation = variationNames.length > 0 ? variationNames[0] : "";
const isConceptExposed = universes.find(
(item) => item.universeName === experimentUniverses.CONCEPT_FUNNEL
)?.isExposed;
const submittedOrder = baseMixin.methods.getSubmittedOrder();
const hasSubmittedOrder = baseMixin.methods.hasSubmittedOrder();
const hasSubmittedOrderAtConfirmationPage =
hasSubmittedOrder && currentPageName?.toLowerCase() == routeData.CONFIRMATION.name;
var payload = {
actionName: `Browser page:${currentPageName}`,
referralSequenceNumber: hasSubmittedOrderAtConfirmationPage
? submittedOrder.referralSequenceNumber
: store.getters.order.referralSequenceNumber,
referralNumber: hasSubmittedOrderAtConfirmationPage
? submittedOrder.referralNumber
: store.getters.order.referralNumber,
workOrderId: hasSubmittedOrderAtConfirmationPage
? submittedOrder.workOrderId
: store.getters.order.workOrderId,
workOrderNumber: hasSubmittedOrderAtConfirmationPage
? submittedOrder.workOrderNumber
: store.getters.order.workOrderNumber,
conceptVariation: conceptVariation,
isConceptExposed: isConceptExposed,
};
await baseMixin.methods.dispatchStoreAction(
storeActions.LOG_DIGITALCONSUMER,
payload,
false
);
},
async pushEventForChatsToGA(category, action, label, pushToLogApp = false) {
const currentPageName = getPageNameFromRouter();
const value = `2.0_${currentPageName}`;

View file

@ -197,6 +197,18 @@ export default {
return [parseInt(v[1], 10), parseInt(v[2], 10), parseInt(v[3] || 0, 10)];
}
},
isMobileDevice() {
const userAgent = navigator.userAgent;
return (
userAgent.includes("Android") ||
userAgent.includes("Mobile") ||
userAgent.includes("iPod") ||
userAgent.includes("iPhone") ||
userAgent.includes("IEMobile") ||
userAgent.includes("BlackBerry") ||
userAgent.includes("webOS")
);
},
getSubmittedOrder() {
return JSON.parse(
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)

View file

@ -49,6 +49,12 @@ const navigationScenarios = {
// Insurance-Company
CLICKED_PAY_ON_MY_OWN: "CLICKED_PAY_ON_MY_OWN",
//Service-Location
CLICKED_FORWARD_WITH_MOBILE_SERVICE: "CLICKED_FORWARD_WITH_MOBILE_SERVICE",
//Customer-details
CLICKED_BACK_WITH_MOBILE_SERVICE: "CLICKED_BACK_WITH_MOBILE_SERVICE",
// Schedule
CLICKED_CHANGE_LOCATION: "CLICKED_CHANGE_LOCATION",

View file

@ -56,6 +56,7 @@ export const routeData = {
path: "/insurance-company",
},
SERVICE_LOCATION: {
// keep even though this page has been removed from the regular flow bc Heritage still points to it
name: "service-location",
path: "/service-location",
},
@ -68,6 +69,10 @@ export const routeData = {
name: "schedule",
path: "/schedule",
},
MOBILE_DETAILS: {
name: "mobile-details",
path: "/mobile-details",
},
CUSTOMER_DETAILS: {
name: "customer-details",
path: "/customer-details",

View file

@ -421,7 +421,7 @@ const routingTable = function () {
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_CASH,
destinationPageData: routeData.SERVICE_LOCATION,
destinationPageData: routeData.SCHEDULE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_INSURANCE,
@ -449,8 +449,26 @@ const routingTable = function () {
},
],
},
// TODO: REMOVE THIS COMMENT AND BELOW, ONCE CASH-803 (SERVICE-LOCATION AND SCHEDULE PAGE COMBINATION) HAS BEEN VETTED
// {
// pageName: routeData.SERVICE_LOCATION.name,
// maps: [
// {
// scenario: navigationScenarios.CLICKED_BACK,
// destinationPageData: routeData.QUOTE,
// },
// {
// scenario: navigationScenarios.CLICKED_FORWARD,
// destinationPageData: routeData.SCHEDULE,
// },
// {
// scenario: navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE,
// destinationPageData: routeData.MOBILE_DETAILS,
// },
// ],
// },
{
pageName: routeData.SERVICE_LOCATION.name,
pageName: routeData.SCHEDULE.name,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
@ -458,25 +476,29 @@ const routingTable = function () {
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationPageData: routeData.SCHEDULE,
destinationPageData: routeData.CUSTOMER_DETAILS,
},
// {
// scenario: navigationScenarios.CLICKED_CHANGE_LOCATION,
// destinationPageData: routeData.SERVICE_LOCATION,
// },
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE,
destinationPageData: routeData.MOBILE_DETAILS,
},
],
},
{
pageName: routeData.SCHEDULE.name,
pageName: routeData.MOBILE_DETAILS.name,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationPageData: routeData.SERVICE_LOCATION,
destinationPageData: routeData.SCHEDULE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationPageData: routeData.CUSTOMER_DETAILS,
},
{
scenario: navigationScenarios.CLICKED_CHANGE_LOCATION,
destinationPageData: routeData.SERVICE_LOCATION,
},
],
},
{
@ -486,6 +508,10 @@ const routingTable = function () {
scenario: navigationScenarios.CLICKED_BACK,
destinationPageData: routeData.SCHEDULE,
},
{
scenario: navigationScenarios.CLICKED_BACK_WITH_MOBILE_SERVICE,
destinationPageData: routeData.MOBILE_DETAILS,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationPageData: routeData.PAYMENT_METHOD,

View file

@ -1,6 +1,9 @@
import analyticsMixin from "@/mixins/analytics-mixin";
export async function afterEach(to, from) {
// digital consumer logging
analyticsMixin.methods.logDigitalConsumer();
// Push page view to GA
analyticsMixin.methods.pushPageViewToGA();

View file

@ -0,0 +1,8 @@
import { routeData } from "@/router/constants/routes";
export async function serviceLocationBeforeEnter(to, from) {
return {
name: routeData.SCHEDULE.name,
replace: true,
};
}

View file

@ -12,6 +12,7 @@ import { errorBeforeEnter } from "@/router/methods/route-logic/error";
import { restartBeforeEnter } from "@/router/methods/route-logic/restart";
import { paymentMethodBeforeEnter } from "@/router/methods/route-logic/payment-method";
import { paymentBeforeEnter } from "@/router/methods/route-logic/payment";
import { serviceLocationBeforeEnter } from "@/router/methods/route-logic/service-location";
export const routes = [
// Non-virtual pages.
@ -29,7 +30,7 @@ export const routes = [
createRoute(routeData.CAPABILITY_QUESTIONS),
createRoute(routeData.QUOTE, quoteBeforeEnter),
createRoute(routeData.INSURANCE_COMPANY, insuranceCompanyBeforeEnter),
createRoute(routeData.SERVICE_LOCATION),
createRoute(routeData.SERVICE_LOCATION, serviceLocationBeforeEnter),
createRoute(routeData.SCHEDULE),
createRoute(routeData.CUSTOMER_DETAILS),
createRoute(routeData.PAYMENT_METHOD, paymentMethodBeforeEnter),
@ -37,6 +38,7 @@ export const routes = [
createRoute(routeData.PAYMENT_PIA_RETURN),
createRoute(routeData.CONFIRMATION),
createRoute(routeData.RETURN_USER),
createRoute(routeData.MOBILE_DETAILS),
// Virtual pages (resolve to a non-virtual page.)
createVirtualRoute(routeData.LANDING, landingBeforeEnter),

View file

@ -11,7 +11,11 @@ import { experimentTriggers } from "@/constants/experiments";
import { damageLocationsSelected } from "@/constants/damage-locations-selected";
import { singleWindshieldCarIds } from "@/constants/single-windshield-carids";
import { routeData } from "@/router/constants/routes";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper.js";
import {
deleteFunnelCookie,
getSessionIdValue,
getDeviceIdValue,
} from "@/helpers/heritage-integration/cookie-helper.js";
import { deepEqual } from "@/helpers/object-helper";
import baseMixin from "@/mixins/base-mixin.js";
import {
@ -421,6 +425,12 @@ export const mutations = {
},
};
},
updateMobileDetails(state, mobileDetails) {
state.order.serviceLocation.address = mobileDetails.address;
state.order.serviceLocation.address2 = mobileDetails.address2;
state.order.serviceLocation.city = mobileDetails.city;
state.order.serviceLocation.isVehicleProtected = mobileDetails.isVehicleProtected;
},
updateServiceLocationTechNotes(state, techNotes) {
state.order.serviceLocation.techNotes = techNotes;
},
@ -1551,6 +1561,38 @@ export const actions = {
});
},
logDigitalConsumer(
context,
{
actionName,
referralSequenceNumber,
referralNumber,
workOrderId,
workOrderNumber,
conceptVariation,
isConceptExposed,
}
) {
var payload = {
sessionId: getSessionIdValue(),
deviceId: getDeviceIdValue(),
actionName: actionName ?? "",
referralSequenceNumber: referralSequenceNumber ?? "",
referralNumber: referralNumber ?? "",
applicationName: baseMixin.methods.isMobileDevice() ? "2.0 Mobile" : "2.0",
workOrderId: workOrderId ?? "",
workOrderNumber: workOrderNumber ?? "",
conceptVariation: conceptVariation,
isConceptExposed: isConceptExposed,
};
return globalMethods.callHttpClient({
method: endpoints.LogDigitalConsumer.method,
endpoint: endpoints.LogDigitalConsumer.url,
payload: payload,
});
},
// Misc Actions
setReferralInformation(context, { referralNumber, referralDate, referralCorrelationId, eon }) {
context.commit(storeMutations.UPDATE_REFERRAL_NUMBER, referralNumber);
@ -2059,7 +2101,7 @@ export const actions = {
return globalMethods.callHttpClient(options);
},
getMobileTimeSlots(context, { payload: { startDate, endDate }, pageNameToLog }) {
getMobileTimeSlots(context, { payload: { startDate, endDate, zipCode }, pageNameToLog }) {
const order = context.state.order;
const vehicle = context.state.order.vehicle;
const payment = context.state.order.payment;
@ -2097,7 +2139,7 @@ export const actions = {
style: vehicle.style,
vin: vehicle.vin ?? "",
},
zipCode: order.serviceLocation.zipCode,
zipCode: zipCode ?? order.serviceLocation.zipCode,
};
let hasCalled = timeSlotCallFlags.mobile;
@ -3204,6 +3246,9 @@ export const actions = {
context.commit(storeMutations.UPDATE_SERVICE_LOCATION, serviceLocationInfo);
},
saveMobileDetails(context, mobileDetails) {
context.commit(storeMutations.UPDATE_MOBILE_DETAILS, mobileDetails);
},
saveServiceLocationTechNotes(context, techNotesInfo) {
context.commit(storeMutations.UPDATE_SERVICE_LOCATION_TECH_NOTES, techNotesInfo);

View file

@ -7,6 +7,7 @@ $black-100: #0a0a0a;
// Blues
$blue-100: #e4f1f7; // Used in theme
$blue-150: #e5f1fa;
$blue-200: #c1dfee;
$blue-300: #9fcee6;
$blue-400: #69adcf; // Used in theme
@ -59,6 +60,7 @@ $gray: #b0b3b3; // Default Gray
$gray-500: #8e9292; // Used in theme
$gray-550: #727676; // Used in theme
$gray-600: #4d5151; // Used in theme
$gray-650: #525656;
$gray-700: #303333; // Used in theme
$gray-800: #222424; // Used in theme
$gray-900: #181a1a; // Used in theme
@ -128,6 +130,9 @@ $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-20: $font-size-base * 1.25; // 20px
//Custom Font size (extra small)
$font-size-xsm: $font-size-base * 0.75;
$font-sizes: (