Merge branch 'develop' into feature/CASH-1947

This commit is contained in:
credelinghuys 2026-03-09 08:48:46 -04:00
commit f86a06a466
27 changed files with 1202 additions and 90 deletions

9
jsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0C6.41775 0 4.87104 0.469192 3.55544 1.34824C2.23985 2.22729 1.21447 3.47672 0.608967 4.93853C0.00346626 6.40034 -0.15496 8.00887 0.153721 9.56072C0.462403 11.1126 1.22433 12.538 2.34315 13.6569C3.46197 14.7757 4.88743 15.5376 6.43928 15.8463C7.99113 16.155 9.59966 15.9965 11.0615 15.391C12.5233 14.7855 13.7727 13.7602 14.6518 12.4446C15.5308 11.129 16 9.58225 16 8C16 5.87827 15.1571 3.84344 13.6569 2.34315C12.1566 0.842855 10.1217 0 8 0V0ZM8.4672 11.4C8.4672 11.5485 8.4082 11.691 8.30318 11.796C8.19816 11.901 8.05572 11.96 7.9072 11.96C7.75868 11.96 7.61624 11.901 7.51122 11.796C7.4062 11.691 7.3472 11.5485 7.3472 11.4V7.08C7.3472 6.93148 7.4062 6.78904 7.51122 6.68402C7.61624 6.579 7.75868 6.52 7.9072 6.52C8.05572 6.52 8.19816 6.579 8.30318 6.68402C8.4082 6.78904 8.4672 6.93148 8.4672 7.08V11.4ZM7.9072 5.4C7.79645 5.4 7.68818 5.36716 7.59608 5.30562C7.50399 5.24409 7.43222 5.15663 7.38983 5.0543C7.34745 4.95198 7.33636 4.83938 7.35796 4.73075C7.37957 4.62212 7.43291 4.52234 7.51122 4.44402C7.58954 4.3657 7.68932 4.31237 7.79795 4.29076C7.90658 4.26915 8.01918 4.28024 8.12151 4.32263C8.22383 4.36501 8.31129 4.43679 8.37283 4.52888C8.43436 4.62097 8.4672 4.72924 8.4672 4.84C8.46741 4.9136 8.45307 4.98651 8.42501 5.05455C8.39694 5.12259 8.3557 5.18441 8.30365 5.23645C8.25161 5.28849 8.18979 5.32973 8.12176 5.3578C8.05372 5.38587 7.9808 5.40021 7.9072 5.4Z" fill="#0070D1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -15,6 +15,8 @@ const GaCategories = {
APPOINTMENT: "Appointment",
SERVICE_LOCATION: "service-location",
CONFIRMATION_CLICKED: "confirmation clicked",
INSHOP_CONFIRMATION_CLICKED: "inshop confirmation clicked",
MOBILE_CONFIRMATION_CLICKED: "mobile confirmation clicked",
RECAL_DISCLAIMER: "Recalibration disclaimer",
};

View file

@ -5,6 +5,7 @@ const experimentUniverses = {
IGQ_SkipQuote: "NextGen_IGQSkipToInsurance",
AFTERPAY_BREAKOUT_DISPLAY: "AfterpayBreakoutDisplay",
MOBILE_FIRST_APPOINTMENT: "MobileFirstAppointment",
MULTI_LOCATION_POPUP: "MultiLocationPopup",
};
const experimentSettings = {
@ -40,7 +41,13 @@ const experimentSettings = {
SHOW_MOBILE_FIRST_POPUP_REPLACE_WITHOUT_MSR: "Show_Mobile_First_Popup_Replace_Without_MSR",
SHOW_MOBILE_FIRST_POPUP_REPLACE_WITH_MSR: "Show_Mobile_First_Popup_Replace_With_MSR",
OFFER_OEM: "OfferOem",
SHOW_MULTI_LOCATION_APPT: "ShowMultiLocationAppt",
SHOW_NO_AVAILABILE_DAYS_MULTI_LOCATION: "Show_NoAvailableDaysMultiLocation",
SHOW_NO_PM_DAYS_MULTI_LOCATION: "Show_NoPMDaysMultiLocation",
SHOW_PM_DAYS_MULTI_LOCATION: "Show_PMDaysMultiLocation",
USE_ADYEN_PAYMENT: "UseAdyenPayment",
SHOW_UNVERIFIED_DEDUCT_ENTRY: "ShowUnverifiedDeducEntry",
SERVICE_PACKAGE_ORDER: "ServicePackageOrder",
};
const experimentTriggers = {

View file

@ -226,6 +226,7 @@ export default {
let classes;
switch (this.buttonTypeString) {
case "timeSlotModalListButton":
case "multiLocationRadioButton":
case "listButton":
classes = "w-100";
break;
@ -265,6 +266,8 @@ export default {
classes += " radio-button-container";
} else if (this.buttonTypeString == "servicePackageRadio") {
classes = "package-wrapper col";
} else if (this.buttonTypeString == "multiLocationRadioButton") {
classes = "multi-location-wrapper col";
}
if (this.buttonTypeString == "listCard" && this.buttonsInfo.length > 2) {

View file

@ -31,23 +31,15 @@
<div class="donation-slider my-4">
<input
name="amount"
id="one"
id="zero"
type="radio"
:value="donationValues[0]"
checked />
<label for="one" class="label">$1</label>
<input
name="amount"
id="three"
type="radio"
:value="donationValues[1]" />
<label for="three" class="label">$3</label>
<input
name="amount"
id="five"
type="radio"
:value="donationValues[2]" />
<label for="five" class="label">$5</label>
<label for="zero" class="label">${{ donationValues[0] }}</label>
<input name="amount" id="one" type="radio" :value="donationValues[1]" />
<label for="one" class="label">${{ donationValues[1] }}</label>
<input name="amount" id="two" type="radio" :value="donationValues[2]" />
<label for="two" class="label">${{ donationValues[2] }}</label>
<div class="indicator-bar"></div>
</div>
</div>
@ -264,17 +256,17 @@ export default {
background: $blue;
}
input {
&#one:checked {
&#zero:checked {
~ .indicator-bar {
transform: translateX(2rem);
}
}
&#three:checked {
&#one:checked {
~ .indicator-bar {
transform: translateX(calc(100% + 2rem));
}
}
&#five:checked {
&#two:checked {
~ .indicator-bar {
transform: translateX(calc(200% + 2rem));
}

View file

@ -317,6 +317,10 @@ export default {
serviceLocationZipCode:
store.getters.order.serviceLocation.zipCode,
pricedLineItems: pricedLineItemsToTax,
serviceLocationAddressLine1:
store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2:
store.getters.order.serviceLocation?.address2,
},
"payment-method",
false

View file

@ -20,7 +20,6 @@ export async function loadSessionIfPresent(isConceptInsurance, pageNameToLog) {
// Do nothing if there is no cookie or session to use for loading.
if (
funnelCookie == null ||
funnelCookie.SavedSessionId == null ||
funnelCookie.ReferralCorrelationId == null ||
!funnelCookie.ReferralNumber
) {
@ -128,7 +127,7 @@ async function loadSession(
const response = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.LOAD_SESSION,
{
savedSessionId: savedSessionId?.toString(),
savedSessionId: savedSessionId != null ? savedSessionId.toString() : null,
referralNumber,
referralDate,
referralCorrelationId,

View file

@ -0,0 +1,11 @@
import store from "@/store";
export function hasVehicleInfo() {
return (
store.getters.order?.vehicle.year &&
store.getters.order?.vehicle?.make &&
store.getters.order?.vehicle?.model &&
store.getters.order?.vehicle?.style &&
store.getters.order?.vehicle?.carId
);
}

View file

@ -470,7 +470,8 @@ export default {
}
},
donationValues() {
return [1, 3, 5];
var donationAmounts = this.getCmsContent("DonationAmountsWidget", "Text");
return donationAmounts.split(",").map(Number);
},
lineItemsWithoutDonation() {
const lineItemsClone = deepClone(this.lineItems);
@ -531,10 +532,11 @@ export default {
window.location.assign(location.protocol + "//" + location.host);
},
async addDonationToOrder() {
const selectedItemNumber = this.donationValues.indexOf(Number(this.donationAmount));
const donationResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.SUBMIT_DONATION_PART_TO_SV2,
{
donationAmount: parseInt(this.donationAmount),
selectedItemNumber: selectedItemNumber,
submittedOrder: this.submittedOrder,
},
false

View file

@ -94,6 +94,9 @@ function setupMocks() {
vehicle: { year: 2020, make: "Toyota", model: "Camry", carId: "12345" },
damage: { isRepair: false },
},
applicationUser: {
experiments: [],
},
policy: {
currentDeductible: 123,
},

View file

@ -53,6 +53,17 @@
v-bind:isDismissible="false" />
</div>
<div>
<alert
ref="UnverifiedInsuranceAlert"
class="mt-5 mb-5"
cmsWidgetName="UnverifiedInsuranceAlertWidget"
v-if="shouldDisplayUnverifiedInsuranceAlert"
:showInfoIcon="true"
:showHorizontalRow="true"
alertClass="alert-info" />
</div>
<div
v-if="isRecalibrationOnOrder && shouldHideRecalibration"
class="questions-about-service mt-5">
@ -311,6 +322,8 @@ export default {
serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode: store.getters.order.serviceLocation.zipCode,
pricedLineItems: lineItemsForCart,
serviceLocationAddressLine1: store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2: store.getters.order.serviceLocation?.address2,
},
"payment-method",
false
@ -515,6 +528,10 @@ export default {
serviceLocationState: this.$store.getters.order.serviceLocation.state,
serviceLocationZipCode: this.$store.getters.order.serviceLocation.zipCode,
pricedLineItems: this.lineItems,
serviceLocationAddressLine1:
this.$store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2:
this.$store.getters.order.serviceLocation?.address2,
},
"payment-method",
false
@ -639,6 +656,8 @@ export default {
serviceLocationState: store.getters.order.serviceLocation.state,
serviceLocationZipCode: store.getters.order.serviceLocation.zipCode,
pricedLineItems: this.lineItems,
serviceLocationAddressLine1: store.getters.order.serviceLocation?.address,
serviceLocationAddressLine2: store.getters.order.serviceLocation?.address2,
},
"payment-method",
false
@ -865,6 +884,13 @@ export default {
shouldDisplayPiaAlert() {
return getBoolFromString(this.$route?.query?.piaError);
},
shouldDisplayUnverifiedInsuranceAlert() {
const shouldDisplayUnverifiedDeduct = experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_UNVERIFIED_DEDUCT_ENTRY,
"true"
);
return shouldDisplayUnverifiedDeduct;
},
// Necessary to make the watcher of lineItems work
// JavaScript does not keep a record of the old value, only a reference to it's location in the memory.
// This creates a separate location for oldValue/newValue so they can be compared
@ -1033,7 +1059,15 @@ export default {
}
}
}
.alert.alert-info.widget-name-UnverifiedInsuranceAlertWidget {
:deep(.alert-heading) {
color: $black;
font-weight: 600;
}
}
}
:deep(span.label),
:deep(span) {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;

View file

@ -35,6 +35,11 @@ jest.mock("@/mixins/experiment-mixin.js", () => ({
return true;
}
},
hasSetting(settingName) {
if (settingName === mockExperimentSettings.PROMO_ON_PACKAGE) {
return false;
}
},
hasSettingEqualTo(settingName, settingValue) {
return false;
},

View file

@ -162,6 +162,16 @@ export default {
}));
this.$emit("currentNumberOfPackages", modifiedAnswers.length);
if (this.experimentPackageOrder()) {
const orderedKeys = this.experimentPackageOrder()
.split(",")
.map((key) => key.trim());
modifiedAnswers.sort(
(a, b) => orderedKeys.indexOf(a.value) - orderedKeys.indexOf(b.value)
);
}
return modifiedAnswers;
},
isDiscountOnOrder() {
@ -234,6 +244,11 @@ export default {
)
);
},
experimentPackageOrder() {
return experimentMixin.methods.hasSetting(experimentSettings.SERVICE_PACKAGE_ORDER)
? experimentMixin.methods.getSettingValue(experimentSettings.SERVICE_PACKAGE_ORDER)
: false;
},
getButtonTypeObject() {
if (this.isAfterpayBreakoutDisplay()) {
return this.servicePackageRadioAfterpayExperiment;

View file

@ -0,0 +1,345 @@
<template>
<div id="multi-location-modal-container">
<modal
:ref="modalName"
:headerText="modalHeaderText"
suppressPageScroll
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalFooterText"
:isFooterButtonPrimary="true"
@footer-button-event="confirmAppointment"
@isModalOpened="setIsModalOpen">
<buttonQuestion
ref="buttonQuestion"
buttonTypeString="multiLocationRadioButton"
:buttonTypeObject="multiLocationRadioButton"
class="mt-5"
:answers="availableAppointments"
groupName="multiLocationQuestion"
textPosition="text-center"
v-model="selectedValue"
isRequired
validationRules="time-slot-required" />
<template v-slot:modal-header-slot>
<span>{{ modalSubHeaderText }}</span>
</template>
<template v-slot:modal-footer-slot>
<button
type="button"
class="btn btn-link"
id="see-more-options"
:disabled="isLoading"
@click="closeModal">
{{ buttonText }}
</button>
</template>
</modal>
</div>
</template>
<script>
// Supporting files
import store from "@/store";
import modal from "@/digital-components/modal/modal";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import { get12HourTimeFormat } from "@/helpers/date-helper";
import { getDisplayTextForDurationLength } from "@/helpers/duration-length-helper";
import buttonQuestion from "@/digital-components/button-question/button-question";
import multiLocationRadioButton from "./multi-location-radio/multi-location-radio";
// Validation
import { defineRule, useField } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
const NULL_APPOINTMENT = {
estimatedServiceMinutes: {
minimum: null,
maximum: null,
},
timeSlot: null,
date: null,
};
// Validation for the modal button
defineRule("time-slot-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "multi-location-modal",
props: {
cmsWidgetName: String,
modalWidgetName: String,
buttonWidgetName: String,
},
data() {
return {
isModalOpen: false,
mobileAppointmentOption: NULL_APPOINTMENT,
inshopAppointmentOption: NULL_APPOINTMENT,
confirmedAppointment: false,
multiLocationRadioButton: multiLocationRadioButton,
selectedValue: null,
experimentSettings: "",
};
},
methods: {
splitCopyOnCMSPlaceHolder,
get12HourTimeFormat,
openModal() {
this.modal.openModal();
},
onModalClosed() {
this.pushEvent();
this.confirmedAppointment = false;
},
closeModal() {
this.modal.closeModal();
},
confirmAppointment() {
if (!this.selectedAppointment.timeSlot.id) return;
const selectedTimeSlot = {
timeSlot: this.selectedAppointment.timeSlot,
routeCode: this.selectedAppointment.timeSlot.id,
date: this.selectedAppointment.date,
};
this.confirmedAppointment = true;
this.$emit("confirm-appointment", {
selectedTimeSlot: selectedTimeSlot,
appointmentType: this.selectedValue,
});
this.modal.closeModal();
},
setPromotedMobileAppointment(appointment) {
this.mobileAppointmentOption = appointment;
},
setPromotedInshopAppointment(appointment) {
this.inshopAppointmentOption = appointment;
},
getIsModalOpen() {
return this.isModalOpen;
},
setIsModalOpen(isOpen) {
this.isModalOpen = isOpen;
},
captureExperimentSettings(settingsString) {
this.experimentSettings = settingsString;
},
pushEvent() {
let gaAction = this.confirmedAppointment ? this.GaActions.YES : this.GaActions.NO;
if (
this.selectedValue?.toLowerCase() === "mobile" &&
this.mobileAppointmentOption?.timeSlot
) {
this.pushEventToGA(
this.GaCategories.MOBILE_CONFIRMATION_CLICKED,
gaAction,
this.mobileAppointmentOption.timeSlot.startTime +
"-" +
this.mobileAppointmentOption.timeSlot.endTime +
" " +
this.mobileAppointmentOption.date,
true,
null,
this.experimentSettings
);
} else if (
this.selectedValue?.toLowerCase() === "inshop" &&
this.inshopAppointmentOption?.timeSlot
) {
this.pushEventToGA(
this.GaCategories.INSHOP_CONFIRMATION_CLICKED,
gaAction,
this.inshopAppointmentOption.timeSlot.startTime +
"-" +
this.inshopAppointmentOption.date,
true,
null,
this.experimentSettings
);
}
},
},
computed: {
selectedAppointment() {
if (this.selectedValue?.toLowerCase() === "mobile") {
return this.mobileAppointmentOption;
} else if (this.selectedValue?.toLowerCase() === "inshop") {
return this.inshopAppointmentOption;
} else {
return NULL_APPOINTMENT;
}
},
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
modalSubHeaderText() {
return this.getCmsContent(this.modalWidgetName, "SubheaderText");
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, "HeaderText");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
buttonText() {
return this.getCmsContent(this.modalWidgetName, "FooterText2");
},
mobileButtonLabel() {
return this.getCmsContent(this.buttonWidgetName, "HeaderText");
},
inshopButtonLabel() {
return this.getCmsContent(this.buttonWidgetName, "SubheaderText");
},
mobileButtonDate() {
if (!this.mobileAppointmentOption.date) return "";
return new Date(this.mobileAppointmentOption.date).toLocaleDateString("en-US", {
timeZone: "UTC",
weekday: "short",
month: "long",
day: "numeric",
});
},
inshopButtonDate() {
if (!this.inshopAppointmentOption.date) return "";
return new Date(this.inshopAppointmentOption.date).toLocaleDateString("en-US", {
timeZone: "UTC",
weekday: "short",
month: "long",
day: "numeric",
});
},
mobileButtonTime() {
const timeslot =
this.get12HourTimeFormat(this.mobileAppointmentOption.timeSlot?.startTime) +
" - " +
this.get12HourTimeFormat(this.mobileAppointmentOption.timeSlot?.endTime);
return timeslot;
},
inshopButtonTime() {
const timeslot = this.get12HourTimeFormat(
this.inshopAppointmentOption.timeSlot?.startTime
);
return timeslot;
},
mobileLocationLabel() {
return this.getCmsContent(this.buttonWidgetName, "BodyText");
},
inshopLocationLabel() {
return this.inshopAppointmentOption?.addressCopy;
},
mobileServiceDuration() {
const durationTime = getDisplayTextForDurationLength(
this.mobileAppointmentOption.estimatedServiceMinutes.minimum,
this.mobileAppointmentOption.estimatedServiceMinutes.maximum
);
const footerText = this.getCmsContent(this.buttonWidgetName, "FooterText").replaceAll(
"{custom:serviceLength}",
durationTime
);
return footerText;
},
inshopServiceDuration() {
const durationTime = getDisplayTextForDurationLength(
this.inshopAppointmentOption.estimatedServiceMinutes.minimum,
this.inshopAppointmentOption.estimatedServiceMinutes.maximum
);
const footerText = this.getCmsContent(this.buttonWidgetName, "FooterText").replaceAll(
"{custom:serviceLength}",
durationTime
);
return footerText;
},
availableAppointments() {
return [
{
value: "mobile",
buttonLabel: this.mobileButtonDate,
buttonLabelSubCopy: this.mobileButtonTime,
buttonBodyCopy: this.mobileLocationLabel,
buttonAuxillaryCopy: this.mobileButtonLabel,
buttonFooterCopy: this.mobileServiceDuration,
},
{
value: "inshop",
buttonLabel: this.inshopButtonDate,
buttonLabelSubCopy: this.inshopButtonTime,
buttonBodyCopy: this.inshopLocationLabel,
buttonAuxillaryCopy: this.inshopButtonLabel,
buttonFooterCopy: this.inshopServiceDuration,
},
];
},
},
components: { modal, buttonQuestion },
};
</script>
<style lang="scss">
#multi-location-modal-container {
.modal-component {
@include media-breakpoint-up(md) {
.modal-dialog {
left: 0;
align-content: center;
flex-wrap: wrap;
width: 22.063rem;
transform: translate(0, 0);
.modal-content {
border-radius: $border-radius-lg;
}
}
}
.modal-dialog {
.modal-content {
.modal-header {
flex-direction: column;
text-align: center;
padding: 0 1rem;
& > span {
color: $red;
font-weight: $font-weight-600;
text-transform: uppercase;
font-family: $font-family-sans-serif-semibold;
}
.modal-title {
font-size: $font-size-20;
font-weight: $font-weight-normal;
}
}
.modal-body {
padding: 0.5rem 1rem;
.button-question {
margin-top: 0.75rem;
}
.modal-body-inner {
ul {
list-style: none;
padding: 1rem;
background-color: #f4f4f4;
font-size: $font-size-14;
}
}
}
.modal-footer {
flex-flow: wrap-reverse;
#see-more-options {
width: 100%;
text-decoration: none;
margin-top: 1.5rem;
font-weight: $font-weight-600;
}
}
}
}
}
}
</style>

View file

@ -0,0 +1,121 @@
import { mount } from "@vue/test-utils";
import multiLocationRadio from "./multi-location-radio";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
describe("multi-location-radio.vue", () => {
it("Should include buttonLabel in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabel"]));
});
it("Should include buttonLabelSubCopy in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonLabelSubCopy"]));
});
it("Should include buttonFooterCopy in html", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const outputHtml = wrapper.html();
// Assert
expect(outputHtml).toEqual(expect.stringContaining(mockProps["buttonFooterCopy"]));
});
it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when provided with a <ul><li>...</li>(x5)</ul> buttonBodyCopy", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
expect(results.length).toBe(5);
});
it("Should get strings from getArrayOfListItemsFromRawCmsCopy without <ul> or <li> tags when provided with a <ul><li>...</li></ul> buttonBodyCopy", async () => {
// Arrange
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: mockProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
const fileredResults = results.filter((result) => {
return (
result.includes("<ul>") ||
result.includes("</ul>") ||
result.includes("<li>") ||
result.includes("</li>")
);
});
expect(fileredResults.length).toBe(0);
});
it("Should get 5 strings from getArrayOfListItemsFromRawCmsCopy when buttonBodyCopy has 6 total <li>...</li>, but one is empty ", async () => {
// Arrange
const moddedProps = mockProps;
moddedProps["buttonBodyCopy"] =
"<ul><li>buttonBodyCopy test copy</li><li>2</li><li>3</li><li>4</li><li>5</li><li></li></ul>";
let { wrapper } = setupMocks({
mountOptionsMockData: {
propsData: moddedProps,
},
});
// Act
const results = wrapper.vm.getArrayOfListItemsFromRawCmsCopy(wrapper.vm.buttonBodyCopy);
// Assert
expect(results.length).toBe(5);
});
});
const mockProps = {
groupName: "group-name",
buttonLabel: "buttonLabel test copy",
buttonLabelAuxillaryCopy: "buttonLabelAuxillaryCopy test copy",
buttonLabelSubCopy: "buttonLabelSubCopy test copy",
buttonBodyCopy:
"<ul><li>buttonBodyCopy test copy</li><li>2</li><li>3</li><li>4</li><li>5</li></ul>",
buttonFooterCopy: "buttonFooterCopy test copy",
buttonAuxillaryCopy: "buttonAuxillaryCopy test copy",
};
function setupMocks({ mountOptionsMockData = {} }) {
const wrapper = mount(multiLocationRadio, {
...mountOptionsMockData,
mixins: [inputButtonWrapperMixin],
});
return { wrapper };
}

View file

@ -0,0 +1,311 @@
<template>
<baseInputButton v-bind="$props" @buttonClicked="handleAnswerChange" v-model="selectedValue">
<div class="radio-label" for="testradio">
<div class="location-specs">
<div class="row">
<div class="col md-6">
<p class="m-0">
<span v-html="this.buttonLabel"></span>
</p>
<span class="service-tag" v-html="this.buttonAuxillaryCopy"></span>
<p
class="sub-label m-0"
v-if="this.buttonLabelSubCopy"
v-html="this.buttonLabelSubCopy"></p>
</div>
<div class="col md-6 location-info">
<div
class="location-info-label"
v-if="this.buttonBodyCopy && this.value === 'mobile'"
v-html="this.buttonBodyCopy"></div>
<div
class="location-info-label"
v-if="this.buttonBodyCopy && this.value === 'inshop'">
<textLink
linkType="newWindowLink"
:text="this.buttonBodyCopy"
:href="inshopHref"
target="_blank" />
</div>
</div>
<div class="col md-6 time-info" v-html="this.buttonFooterCopy"></div>
</div>
</div>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import textLink from "@/ux-components/text-link/text-link";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
import {
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
} from "@/helpers/cms-content-helper";
import baseMixin from "@/mixins/base-mixin.js";
export default {
name: "multiLocationRadioButton",
mixins: [inputButtonWrapperMixin],
components: {
baseInputButton,
textLink,
},
computed: {
arrayOfListItemsFromBodyText() {
return this.getArrayOfListItemsFromRawCmsCopy(this.buttonBodyCopy);
},
shouldDisplayStrikeThroughPrice() {
const displayPrice = this.buttonAuxillaryCopy?.substring(
this.buttonAuxillaryCopy.indexOf("$")
);
return displayPrice != this.additionalButtonData?.strikeThroughPrice;
},
isAppleBrowser() {
return baseMixin.methods.isAppleBrowser();
},
inshopHref() {
if (this.isAppleBrowser) {
return "https://maps.apple.com/?q=" + encodeURIComponent(this.buttonBodyCopy) + '"';
}
return (
"https://www.google.com/maps/search/?api=1&query=" +
encodeURIComponent(this.buttonBodyCopy) +
'"'
);
},
},
methods: {
getRouterLinkDisplayTextFromCopy,
getRouterLinkRouteFromCopy,
splitCopyOnCMSPlaceHolder,
doesCopyContainTextLink,
stripUlTagFromCopy(copy) {
const regex = /(?:<ul(?:.*?)>)|(?:<\/ul>)/g;
return copy?.replace(regex, "");
},
getArrayOfListItemsFromRawCmsCopy(copy) {
const withoutUlTags = this.stripUlTagFromCopy(copy);
return withoutUlTags
?.split(/(?:<li(?:.*?)>)|(?:<\/li>)/g)
?.filter((lineItem) => lineItem);
},
},
};
</script>
<style lang="scss">
.multi-location-wrapper {
margin: 1rem 0;
&:first-child {
margin: 1rem 0 0 0;
}
&:last-child {
margin: 1rem 0 0 0;
}
label {
display: block;
@include media-breakpoint-up(md) {
display: flex;
height: 100%;
}
}
input[type="radio"] {
opacity: 0;
position: absolute;
left: -9999px;
+ .radio-label {
display: flex;
align-items: flex-start;
position: relative;
cursor: pointer;
width: 100%;
padding: 0.75rem;
border: 1px solid $gray-300;
box-shadow:
0px 4px 8px -4px rgba(0, 0, 0, 0.15),
0px 4px 24px -8px rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
overflow: hidden;
min-height: 52px;
max-height: 126px;
@include media-breakpoint-up(md) {
padding-bottom: 3rem;
}
transition: max-height 0.25s ease-in;
&.has-subheader {
min-height: 72px;
}
&:before {
content: "";
position: relative;
top: 5px;
margin-right: 0.5rem;
border-radius: 50%;
border: 1px solid $gray-500;
width: 16px;
height: 16px;
min-width: 16px;
}
&:after {
content: "";
position: absolute;
left: 15px;
top: 20px;
border-radius: 50%;
width: 10px;
height: 10px;
min-width: 10px;
}
}
&:hover {
+ .radio-label {
&:before {
border: 1px solid #8e9292;
box-shadow:
0px 0px 0px 4px #9fcee6,
0px 1px 4px rgba(0, 0, 0, 0.2);
}
}
}
&:checked {
+ .radio-label {
.location-specs {
.hide-when-closed {
display: block;
}
.service-tag {
background: $blue;
color: $blue-150;
}
}
}
+ .radio-label {
border: 1px solid $blue;
background-color: $blue-100;
}
+ .radio-label {
&:before {
box-shadow: 0px 0px 0px 1px $blue;
}
}
+ .radio-label {
&:after {
background: $blue;
}
}
}
&:focus {
+ .radio-label {
&:before {
border: 2px solid $blue;
}
}
}
}
.location-specs {
display: flex;
flex-direction: column;
width: 100%;
transition: all 0.5s ease;
.row {
align-items: center;
.col {
flex: 1 0 100%;
}
.service-tag {
font-size: $font-size-12;
font-family: $font-family-sans-serif-semibold;
position: absolute;
top: 0;
right: 0;
background: $blue-150;
color: $blue;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
margin: 0.5rem;
}
p {
font-size: $font-size-14;
& > span {
color: $black;
font-family: $font-family-sans-serif-semibold;
font-weight: $font-weight-bold;
font-size: $font-size-base;
}
}
.sub-label {
margin-bottom: 0.5rem !important;
border-bottom: 1px solid $gray-1000;
}
}
.location-info-label,
.time-info {
font-family: $font-family-sans-serif;
font-size: $font-size-12;
}
.location-info-label {
display: flex;
align-items: center;
&:before {
content: "";
display: inline-block;
position: relative;
top: 0;
margin-right: 0.375rem;
width: 0.75rem;
height: 1rem;
background-image: url(~@/assets/img/icons/location-pin.svg);
background-repeat: no-repeat;
background-position: 0 50%;
background-size: 0.75rem auto;
padding: 0 0.75rem 0 0;
@include blue-filter();
}
& > a.new-window-link {
margin-bottom: 0;
font-family: $font-family-sans-serif;
font-size: $font-size-12;
padding-bottom: 0;
}
}
@keyframes slideaway {
from {
display: block;
}
to {
transform: translateY(40px);
opacity: 0;
}
}
.hide-when-closed {
display: none;
@include media-breakpoint-up(md) {
display: flex;
flex-direction: column;
}
}
}
}
</style>

View file

@ -5,7 +5,12 @@
<mobileFirstModal
modalWidgetName="MobileFirstModalWidget"
ref="mobileFirstModal"
@confirm-appointment="updateTimeSlotandNavigateForward" />
@confirm-appointment="updateMobileFirstTimeSlotandNavigateForward" />
<multiLocationModal
modalWidgetName="MultiLocationModalWidget"
buttonWidgetName="MultiLocationRadioButtonWidget"
ref="multiLocationModal"
@confirm-appointment="updateMultiLocationTimeSlotandNavigateForward" />
<recalAckModal
modalWidgetName="RecalAckModalWidget"
agreementWidgetName="RecalAgreementQuestionWidget"
@ -14,7 +19,9 @@
ref="recalAckModal" />
<div
class="container page-container-grouped-styles"
:class="[isMobileFirstModalOpen ? 'hidden-background' : '']">
:class="[
isMobileFirstModalOpen || isMultiLocationModalOpen ? 'hidden-background' : '',
]">
<div class="row">
<div class="col-12 col-md-10 col-lg-8 col-xl-7">
<mobileFeeWaiverAlert
@ -28,7 +35,6 @@
cmsWidgetName="ScheduleYourServiceWidget"
id="schedule-your-service" />
<appointmentTypeQuestion
v-if="hasSelectableDatesLoaded"
v-model="appointmentTypeFromAppointmentTypeQuestion"
v-show="isAppointmentTypeDisplayed"
:isServiceableMobile="isServiceableMobile"
@ -36,7 +42,6 @@
:isDisplayed="isAppointmentTypeDisplayed"
:mobileFeeApplies="mobileFeeApplies"
:zipCode="zipCode"
ref="appointmentTypeQuestion"
groupName="appointmentTypeQuestion"
cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required"
@ -193,6 +198,7 @@ import shopLocation from "@/layouts/schedule/shop-location/shop-location";
import timeSlotQuestion from "@/layouts/schedule/time-slot-question/time-slot-question.vue";
import durationTextBlock from "@/layouts/schedule/duration-text-block/duration-text-block.vue";
import mobileFirstModal from "@/layouts/schedule/mobile-first-modal/mobile-first-modal.vue";
import multiLocationModal from "@/layouts/schedule/multi-location-modal/multi-location-modal.vue";
import recalAckModal from "@/layouts/schedule/recal-ack-modal/recal-ack-modal.vue";
import shopQuestion from "@/layouts/service-location/shop-question/shop-question";
@ -479,10 +485,7 @@ export default {
shopListButton: shopListButton,
lastSelectedInshopOrDropoffProvider: null,
selectedMobileFirstAppointment: false,
isShowMobileFirstAppt: experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MOBILE_FIRST_APPT,
"true"
),
selectedMultiLocationAppointment: false,
calendarLoadingStatus: "none",
};
},
@ -615,7 +618,8 @@ export default {
shopProviderData.data
);
vm.initializeDatePicker().then(() => {
vm.showMobileFirstModal();
// vm.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST
vm.showMultiLocationModal();
vm.hideLoadingModal();
vm.calendarLoadingStatus = "none";
if (!vm.preSelectedDate) vm.setSelectedDateToFirstAvailable();
@ -904,6 +908,11 @@ export default {
? this.$refs.mobileFirstModal?.getIsModalOpen()
: false;
},
isMultiLocationModalOpen() {
return this.selectableDatesMobile.days && this.selectableDatesInshop.days
? this.$refs.multiLocationModal?.getIsModalOpen()
: false;
},
displayWaitList() {
if (!this.appointmentType) return false;
const firstMobileDateString =
@ -952,12 +961,24 @@ export default {
return false;
},
hasSelectableDatesLoaded() {
if (this.isMobileSelected) {
if (this.isServiceableMobile) {
return this.selectableDatesMobile?.days?.length > 0;
} else {
return this.selectableDatesInshop?.days?.length > 0;
}
},
isShowMobileFirstAppt() {
return experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MOBILE_FIRST_APPT,
"true"
);
},
isShowMultiLocationPopup() {
return experimentMixin.methods.hasSettingEqualTo(
experimentSettings.SHOW_MULTI_LOCATION_APPT,
"true"
);
},
},
methods: {
splitCopyOnCMSPlaceHolder,
@ -1243,7 +1264,8 @@ export default {
}
}
this.initializeDatePicker().then(() => {
this.showMobileFirstModal();
// this.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST
this.showMultiLocationModal();
this.hideLoadingModal();
this.calendarLoadingStatus = "none";
this.setSelectedDateToFirstAvailable();
@ -1277,7 +1299,7 @@ export default {
},
async initializeDatePicker() {
this.isShowMobileFirstAppt && this.showLoadingModal();
this.isShowMultiLocationPopup && this.showLoadingModal();
const includeMobileTimeSlots = this.isServiceableMobile;
const includeInshopTimeSlots = this.isServiceableInshop || this.isServiceableDropoff;
@ -1802,7 +1824,8 @@ export default {
this.zipCodeCtu = newZipCode.zipCodeCtu;
this.selectedDate = null;
this.initializeDatePicker().then(() => {
this.showMobileFirstModal();
// this.showMobileFirstModal(); // KEEP IN CASE WE WANT TO REINSTATE MOBILE FIRST
this.showMultiLocationModal();
this.hideLoadingModal();
this.calendarLoadingStatus = "none";
this.setSelectedDateToFirstAvailable();
@ -1857,10 +1880,13 @@ export default {
} else {
this.appointmentType = null;
}
this.setSelectedDateToFirstAvailable(); // v-if on appointmentTypeQuestion ensures dates have been loaded by now
this.setSelectedDateToFirstAvailable();
},
setSelectedDateToFirstAvailable() {
this.selectedDate = this.getFirstAvailableDate();
if (this.hasSelectableDatesLoaded) {
// ensure dates have been loaded by now
this.selectedDate = this.getFirstAvailableDate();
}
},
showRecalAcknowledgementModal() {
if (this.shouldShowRecalAckModal()) {
@ -1914,10 +1940,10 @@ export default {
const order = this.$store.getters.order;
const isRepair = order.damage.isRepair;
let preSelectedMobileAppointment = null;
let promotedMobileAppointment = null;
const todaysDate = getTodayDate();
let firstMobileAMAppt = await this.getFirstAvailableMobileApptByTOD("AM");
let firstMobilePMAppt = await this.getFirstAvailableMobileApptByTOD("PM");
let firstMobileAMAppt = await this.getFirstAvailableApptByTOD("AM", "mobile");
let firstMobilePMAppt = await this.getFirstAvailableApptByTOD("PM", "mobile");
if (!firstMobileAMAppt && !firstMobilePMAppt) {
return;
}
@ -2012,7 +2038,7 @@ export default {
if (this.isShowMobileFirstAppt) {
if (pmMobileDays == 0) {
// Selects the first available time slot
preSelectedMobileAppointment =
promotedMobileAppointment =
numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate
? firstMobileAMAppt
: firstMobilePMAppt;
@ -2021,20 +2047,20 @@ export default {
numberOfDaysToFirstMobilePMDate <= pmMobileDays
) {
// Selects the first available PM time slot
preSelectedMobileAppointment = firstMobilePMAppt;
promotedMobileAppointment = firstMobilePMAppt;
} else if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate >= noPMMobileDays
) {
// Selects the first available AM time slot. If none, selects the first available PM time slot
preSelectedMobileAppointment = firstMobileAMAppt
promotedMobileAppointment = firstMobileAMAppt
? firstMobileAMAppt
: firstMobilePMAppt;
}
if (preSelectedMobileAppointment) {
if (promotedMobileAppointment) {
this.$refs.mobileFirstModal.setSelectedAppointment(
preSelectedMobileAppointment
promotedMobileAppointment
);
this.$refs.mobileFirstModal.openModal();
}
@ -2042,11 +2068,176 @@ export default {
}
}
},
async getFirstAvailableMobileApptByTOD(timeOfDay) {
if (!this.selectableDatesMobile?.days?.length) {
async showMultiLocationModal() {
if (!this.selectedRouteCodeData?.routeCode) {
let maxDayRangeToShowPmTimeslot = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_PM_DAYS_MULTI_LOCATION
);
let maxDayRangeToShowNoPmTimeslot = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_PM_DAYS_MULTI_LOCATION
);
let maxDayRangeToShowAnyTimeslot = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_NO_AVAILABILE_DAYS_MULTI_LOCATION
);
let showMultiLocationAppointment = experimentMixin.methods.getSettingValue(
experimentSettings.SHOW_MULTI_LOCATION_APPT
);
let experimentSettingsString =
"ShowPm:" +
maxDayRangeToShowPmTimeslot +
"|" +
"NoPm:" +
maxDayRangeToShowNoPmTimeslot +
"|" +
"NoAvail:" +
maxDayRangeToShowAnyTimeslot;
let promotedMobileAppointment = null;
let promotedInshopAppointment = null;
const todaysDate = getTodayDate();
// Helper function to calculate days until appointment (handles null/undefined)
const calculateDaysUntilDate = (appointmentObj) => {
const dateString = appointmentObj?.date;
return dateString
? (new Date(dateString) - todaysDate) / (1000 * 60 * 60 * 24)
: null;
};
// Fetch all appointments
const [firstMobileAMAppt, firstMobilePMAppt, firstInshopAMAppt, firstInshopPMAppt] =
await Promise.all([
this.getFirstAvailableApptByTOD("AM", "mobile"),
this.getFirstAvailableApptByTOD("PM", "mobile"),
this.getFirstAvailableApptByTOD("AM", "inshop"),
this.getFirstAvailableApptByTOD("PM", "inshop"),
]);
// Extract dates
const firstMobileAMDate = firstMobileAMAppt?.date;
const firstMobilePMDate = firstMobilePMAppt?.date;
const firstInshopAMDate = firstInshopAMAppt?.date;
const firstInshopPMDate = firstInshopPMAppt?.date;
// Calculate days until appointments
const numberOfDaysToFirstMobileAMDate = calculateDaysUntilDate(firstMobileAMAppt);
const numberOfDaysToFirstMobilePMDate = calculateDaysUntilDate(firstMobilePMAppt);
const numberOfDaysToFirstInshopAMDate = calculateDaysUntilDate(firstInshopAMAppt);
const numberOfDaysToFirstInshopPMDate = calculateDaysUntilDate(firstInshopPMAppt);
const shouldExposeMultiLocationModal = () => {
const isMobileAppointmentInDateRange =
(firstMobileAMDate &&
numberOfDaysToFirstMobileAMDate <= maxDayRangeToShowAnyTimeslot) ||
(firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= maxDayRangeToShowAnyTimeslot);
const isInshopAppointmentInDateRange =
(firstInshopAMDate &&
numberOfDaysToFirstInshopAMDate <= maxDayRangeToShowAnyTimeslot) ||
(firstInshopPMDate &&
numberOfDaysToFirstInshopPMDate <= maxDayRangeToShowAnyTimeslot);
if (showMultiLocationAppointment?.toLowerCase() === "true") {
return isMobileAppointmentInDateRange && isInshopAppointmentInDateRange;
}
return false;
};
if (shouldExposeMultiLocationModal()) {
const multiLocationPopupExperiment =
store.getters.applicationUser.experiments.find(
(e) => e.universeName === experimentUniverses.MULTI_LOCATION_POPUP
);
const hasExposedMultiLocationPopup = multiLocationPopupExperiment?.isExposed;
if (!hasExposedMultiLocationPopup && multiLocationPopupExperiment) {
baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.LOG_EXPERIMENT_EXPOSURE_AND_UPDATE_STORE,
{
userId: getUserIdValue(),
deviceId: getDeviceIdValue(),
sessionKey: getSessionKeyValue(),
pageName: "schedule",
experiment: multiLocationPopupExperiment,
},
"schedule",
false
);
}
if (this.isShowMultiLocationPopup) {
if (maxDayRangeToShowPmTimeslot == 0) {
// Selects the first available mobile time slot
promotedMobileAppointment =
numberOfDaysToFirstMobileAMDate <= numberOfDaysToFirstMobilePMDate
? firstMobileAMAppt
: firstMobilePMAppt;
// Selects the first available inshop time slot
promotedInshopAppointment =
numberOfDaysToFirstInshopAMDate <= numberOfDaysToFirstInshopPMDate
? firstInshopAMAppt
: firstInshopPMAppt;
} else {
if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate <= maxDayRangeToShowPmTimeslot
) {
// Selects the first available PM time slot
promotedMobileAppointment = firstMobilePMAppt;
} else if (
firstMobilePMDate &&
numberOfDaysToFirstMobilePMDate >= maxDayRangeToShowNoPmTimeslot
) {
// Selects the first available AM time slot. If none, selects the first available PM time slot
promotedMobileAppointment = firstMobileAMAppt
? firstMobileAMAppt
: firstMobilePMAppt;
}
if (
firstInshopPMDate &&
numberOfDaysToFirstInshopPMDate <= maxDayRangeToShowPmTimeslot
) {
// Selects the first available PM time slot
promotedInshopAppointment = firstInshopPMAppt;
} else if (
firstInshopPMDate &&
numberOfDaysToFirstInshopPMDate >= maxDayRangeToShowNoPmTimeslot
) {
// Selects the first available AM time slot. If none, selects the first available PM time slot
promotedInshopAppointment = firstInshopAMAppt
? firstInshopAMAppt
: firstInshopPMAppt;
}
}
if (promotedMobileAppointment && promotedInshopAppointment) {
this.$refs.multiLocationModal.setPromotedMobileAppointment(
promotedMobileAppointment
);
this.$refs.multiLocationModal.setPromotedInshopAppointment(
promotedInshopAppointment
);
this.$refs.multiLocationModal.openModal();
this.$refs.multiLocationModal.captureExperimentSettings(
experimentSettingsString
);
}
}
}
}
},
async getFirstAvailableApptByTOD(timeOfDay, appointmentType) {
let selectableDays;
if (appointmentType?.toLowerCase() == "mobile") {
selectableDays = this.selectableDatesMobile?.days;
} else if (appointmentType?.toLowerCase() == "inshop") {
selectableDays = this.selectableDatesInshop?.days;
} else {
return;
}
if (!selectableDays?.length) {
return null;
} else {
for (const dateObj of this.selectableDatesMobile.days) {
for (const dateObj of selectableDays) {
let matchingTimeSlot = {
estimatedServiceMinutes: {
minimum: this.estimatedServiceMinutesMinimum,
@ -2054,6 +2245,7 @@ export default {
},
timeSlot: null,
date: null,
addressCopy: null,
};
const isMatchingApptDay = dateObj.timeSlots.some(
(slot) =>
@ -2061,6 +2253,16 @@ export default {
(matchingTimeSlot.timeSlot = slot) &&
(matchingTimeSlot.date = dateObj.date)
);
if (
appointmentType?.toLowerCase() == "inshop" &&
this.selectedShopAnswer?.address1 &&
this.selectedShopAnswer?.address2
) {
matchingTimeSlot.addressCopy =
this.selectedShopAnswer?.address1 +
", " +
this.selectedShopAnswer?.address2;
}
if (isMatchingApptDay && matchingTimeSlot) {
return matchingTimeSlot;
}
@ -2068,7 +2270,7 @@ export default {
return null;
}
},
updateTimeSlotandNavigateForward(timeSlotObj) {
updateMobileFirstTimeSlotandNavigateForward(timeSlotObj) {
this.selectedMobileFirstAppointment = true;
this.appointmentType = AppointmentTypeStrings.MOBILE;
this.selectedDate = timeSlotObj.date;
@ -2076,6 +2278,18 @@ export default {
this.updateTimeSlot(timeSlotObj);
this.forwardButtonAction();
},
updateMultiLocationTimeSlotandNavigateForward({ selectedTimeSlot, appointmentType }) {
if (appointmentType?.toLowerCase() == "mobile") {
this.appointmentType = AppointmentTypeStrings.MOBILE;
} else if (appointmentType?.toLowerCase() == "inshop") {
this.appointmentType = AppointmentTypeStrings.IN_SHOP;
}
this.selectedMultiLocationAppointment = true;
this.selectedDate = selectedTimeSlot.date;
this.updateSelectedProvider();
this.updateTimeSlot(selectedTimeSlot);
this.forwardButtonAction();
},
updateRecalAcknowledgedAndNavigateForward(isAcknowledged) {
this.isRecalAcknowledgedForScheduling = isAcknowledged;
this.forwardButtonAction();
@ -2084,7 +2298,7 @@ export default {
watch: {
appointmentTypeFromAppointmentTypeQuestion: {
handler(newValue, oldValue) {
this.handleAppointmentTypeChange(newValue); // v-if on appointmentTypeQuestion ensures dates have been loaded by now
this.handleAppointmentTypeChange(newValue);
},
},
},
@ -2099,6 +2313,7 @@ export default {
textBlock,
timeSlotQuestion,
mobileFirstModal,
multiLocationModal,
recalAckModal,
alert,
serviceZipModalQuestion,

View file

@ -200,6 +200,13 @@ export default {
}
}
baseMixin.methods.ResetExternalParamsAndHideModal();
//clear any validation errors for external parameter flow
const form = vm.$refs.theForm;
if (form) {
form.setFieldError("DamageLocationQuestion", undefined);
form.setFieldTouched("DamageLocationQuestion", false, false);
}
}
} else {
baseMixin.methods.ResetExternalParamsAndHideModal();

View file

@ -291,6 +291,7 @@ export default {
sessionData.isPia = order?.payment?.isPia ?? false;
sessionData.piaType = order?.payment?.piaType;
sessionData.parentAccountNumber = order?.payment?.parentAccountNumber;
sessionData.billToAccountNumber = order?.payment?.billToAccountNumber;
sessionData.settledTenderAmount = order?.settledTenderAmount;
sessionData.recalRequired = containsRecalParts(order?.lineItems);
sessionData.recalType = getRecalPartNumbers(order?.lineItems?.glassParts);

View file

@ -218,6 +218,15 @@ export default {
userAgent.includes("webOS")
);
},
isAppleBrowser() {
const userAgent = navigator.userAgent;
return (
userAgent.includes("iPod") ||
userAgent.includes("iPad") ||
userAgent.includes("iPhone") ||
userAgent.includes("Mac")
);
},
getSubmittedOrder() {
return JSON.parse(
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)

View file

@ -15,11 +15,14 @@ export async function consumeReferralQuerystrings() {
// If no referral number in querystring, check if heritage funnel updated last.
// This would indicate a customer that went to heritage but did not return through the
// normal route. ie: left the site in search of a discount and returned without querystrings.
//or if a referral number is in query string check if heritage funnel updated last
// This would indicate a customer that returned through normal route like from sfa email
const didHeritageFunnelUpdateLast = getFunnelCookie()?.DidHeritageFunnelUpdateLast;
if (didHeritageFunnelUpdateLast && !referralNumber) {
referralNumber = getFunnelCookie().ReferralNumber;
parentAccount = getFunnelCookie().ReferralParentAccountNumber;
correlationId = getFunnelCookie().ReferralCorrelationId;
if (didHeritageFunnelUpdateLast) {
referralNumber = referralNumber ?? getFunnelCookie().ReferralNumber;
parentAccount = parentAccount ?? getFunnelCookie().ReferralParentAccountNumber;
correlationId = correlationId ?? getFunnelCookie().ReferralCorrelationId;
referralDate = getFunnelCookie().ReferralDate;
savedSessionId = getFunnelCookie().SavedSessionId;
}

View file

@ -3,6 +3,7 @@ import { getBoolFromString } from "@/helpers/boolean-helper";
import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { routeData } from "@/router/constants/routes";
import { isVerifiedInsurance } from "@/router/methods/helpers/requires-verified-redirect";
import { hasVehicleInfo } from "@/helpers/vehicle-helper";
import store from "@/store";
import { queryStrings } from "@/constants/query-strings";
import baseMixin from "@/mixins/base-mixin";
@ -33,7 +34,7 @@ export async function vehicleBeforeEnter(to, from) {
}
}
if (isVerifiedInsurance()) {
if (isVerifiedInsurance() && hasVehicleInfo()) {
return {
name: routeData.VEHICLE_DAMAGE.name,
replace: true,

View file

@ -1645,6 +1645,7 @@ export const actions = {
isPia,
piaType,
parentAccountNumber,
billToAccountNumber,
settledTenderAmount,
recalRequired,
recalType,
@ -1718,6 +1719,7 @@ export const actions = {
totalPrice: totalPrice,
userAgent: userAgent,
cashPriceSubTotal: cashPriceSubTotal,
billToAccountNumber: billToAccountNumber,
};
return globalMethods.callHttpClient({
@ -2012,52 +2014,27 @@ export const actions = {
},
getServiceabilityDetails(context, { payload: { serviceZipCode }, pageNameToLog }) {
const escapeRecalibrationType = (rt) => rt.split("&").join("%26");
const partialLineItemsObjects = context.getters.order.lineItems.glassParts?.map(
(lineItem) => ({
partNumber: lineItem.partNumber,
recalibrationType:
lineItem.recalibrationType && isRecalPartOrHasChildRecalPart(lineItem)
? escapeRecalibrationType(lineItem.recalibrationType)
: undefined,
})
const flattenedLineItemsWithChildParts = getFlattenedArrayOfLineItemsWithChildParts(
context.getters.order.lineItems.glassParts
);
var lineItems = null;
if (partialLineItemsObjects) {
lineItems = buildQueryStringParameterFromArrayOfComplexObjects(
partialLineItemsObjects,
"lineItems"
);
}
const lineItemsList = flattenedLineItemsWithChildParts
.map((item) => item.partNumber)
.join(",");
const vehicle = context.getters.vehicle;
const carId = vehicle.carId;
const damage = context.getters.damage;
const glassArray = convertGlassPieceNamingForApi(damage.glassToReplace);
const glassPieces = buildQueryStringParameterFromArrayOfComplexObjects(
glassArray,
"glassPieces"
);
const parentAccountNumber =
context.getters.order.payment.parentAccountNumber ??
applicationConfig.CASH_PARENT_ACCOUNT_NUMBER;
const referralSequenceNumber = context.getters.order.referralSequenceNumber;
var endPoint = `${endpoints.GetServiceabilityDetails.url}?zip=${serviceZipCode}&carId=${carId}&parentAccountNumber=${parentAccountNumber}&referralSequenceNumber=${referralSequenceNumber}&applicationName=${applicationConfig.ANALYTICS_APPLICATION_NAME}`;
if (lineItems) {
endPoint += `&${lineItems}`;
var endPoint = `${endpoints.GetServiceabilityDetails.url}?zip=${serviceZipCode}&carId=${carId}&parentAccountNumber=${parentAccountNumber}&isRepair=${damage.isRepair}`;
if (lineItemsList) {
endPoint += `&lineItems=${lineItemsList}`;
}
if (glassPieces) {
endPoint += `&${glassPieces}`;
}
endPoint += `&isHeavyTruckVehicle=${context.getters.order.vehicle.isBigTruck}`;
return globalMethods.callHttpClient({
method: endpoints.GetServiceabilityDetails.method,
endpoint: endPoint,
@ -2126,14 +2103,14 @@ export const actions = {
},
async submitDonationPartToSV2(
context,
{ payload: { donationAmount, submittedOrder }, pageNameToLog }
{ payload: { selectedItemNumber, submittedOrder }, pageNameToLog }
) {
const state = JSON.parse(
window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE)
);
const order = state.order;
const payload = {
donationAmount: parseInt(donationAmount),
selectedItemNumber: selectedItemNumber,
savedSessionId: state.applicationUser.savedSessionId,
paymentInfo: {
parentAccountNumber: order.payment.parentAccountNumber,
@ -3138,6 +3115,8 @@ export const actions = {
serviceLocationState,
serviceLocationZipCode,
pricedLineItems,
serviceLocationAddressLine1,
serviceLocationAddressLine2,
},
pageNameToLog,
}
@ -3171,6 +3150,8 @@ export const actions = {
appointmentType: appointmentType,
pricedLineItems: lineItemsWithOnlyPriceInfo,
serviceLocation: {
addressLine1: appointmentType == "Mobile" ? serviceLocationAddressLine1 : null,
addressLine2: appointmentType == "Mobile" ? serviceLocationAddressLine2 : null,
city: appointmentType == "Mobile" ? serviceLocationCity : null,
state: appointmentType == "Mobile" ? serviceLocationState : null,
zipCode: appointmentType == "Mobile" ? serviceLocationZipCode : null,

View file

@ -20,3 +20,9 @@
}
// Usage: @include responsive-font-size-md(mobile-font-size-in-rem, desktop-font-size-in-rem);
// Example: @include responsive-font-size-md(0.875rem, 1rem);
// Filter to use on SVG elements that are background
@mixin blue-filter {
filter: brightness(0) saturate(100%) invert(31%) sepia(68%) saturate(1676%) hue-rotate(186deg)
brightness(93%) contrast(110%);
}

View file

@ -127,6 +127,12 @@ $body-color: $gray-600;
$font-family-sans-serif: UrbanistRegular, Arial, Helvetica, sans-serif;
$font-family-monospace:
UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
$font-family-sans-serif-semibold: UrbanistSemibold, Arial, Helvetica, sans-serif;
$font-family-sans-serif-bold: UrbanistBold, Arial, Helvetica, sans-serif;
$font-family-sans-serif-extrabold: UrbanistExtraBold, Arial, Helvetica, sans-serif;
$font-family-sans-serif-medium-italic: UrbanistMedium_Italic, Arial, Helvetica, sans-serif;
$font-family-sans-serif-bold-italic: UrbanistBold_Italic, Arial, Helvetica, sans-serif;
// stylelint-enable value-keyword-case
$font-family-base: $font-family-sans-serif;
$font-family-code: $font-family-monospace;

View file

@ -8,7 +8,11 @@
this.alertClass,
this.cssClassNameForCmsWidget,
]">
<p class="mx-6 my-0 fw-bold alert-heading">{{ alertHeadline }}</p>
<p class="mx-6 my-0 fw-bold alert-heading">
<img v-if="showInfoIcon" :src="infoIcon" alt="Info Icon" class="info-icon" />
{{ alertHeadline }}
</p>
<hr v-if="showHorizontalRow" />
<template v-for="paragraph in splitAlertCopyForParagraphTag" :key="paragraph">
<p
class="mx-4 mt-1 mb-0 text-body small"
@ -58,6 +62,8 @@ import textBlock from "@/digital-components/text-block/text-block";
export default {
name: "alert",
props: {
showInfoIcon: Boolean,
showHorizontalRow: Boolean,
isDismissible: Boolean,
alertClass: String,
/*
@ -87,6 +93,9 @@ export default {
},
},
computed: {
infoIcon() {
return require(`@/assets/img/icons/info-circle-blue.svg`);
},
pageQueryString() {
return applicationConfig.PAGE_QUERYSTRING;
},
@ -148,6 +157,12 @@ export default {
border-radius: 0.5rem;
border-width: 0px;
hr {
border: 1px solid $blue;
margin-left: 1rem;
margin-right: 1rem;
}
button {
display: none;
}