Merge remote-tracking branch 'origin/release/2025.07.10' into feature/CASH-816

This commit is contained in:
Johnny shultz 2025-06-30 17:07:10 -04:00
commit adfb325fae
80 changed files with 1913 additions and 933 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@ -15,6 +15,7 @@ const pagePercentageMapper = {
"insurance-company": 52,
"service-location": 60,
schedule: 72,
"mobile-details": 76,
"customer-details": 84,
"payment-method": 92,
payment: 96,

View file

@ -2,6 +2,7 @@ const AppointmentTypeStrings = {
IN_SHOP: "Inshop",
MOBILE: "Mobile",
DROP_OFF: "Dropoff",
IN_SHOP_OR_DROP_OFF: "InshopOrDropoff",
};
const PREMIUM_TIME_SLOT_ID_FLAG = "-PREMIUM";

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

@ -351,6 +351,7 @@ export default {
}
}
.button-question {
font-family: UrbanistSemibold;
color: $black;
.radio-button-container {

View file

@ -42,19 +42,36 @@ const mockValuesForSetDate01 = {
initialViewEndDate: "2023-12-30",
calendarViewDirection: "future",
initialShopTimeSlotsResponse: {
days: [
{
date: "2023-12-30",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
inshopTimeSlotsData: {
days: [
{
date: "2023-12-30",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
mobileTimeSlotsData: {
days: [
{
date: "2023-12-30",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
},
@ -68,194 +85,195 @@ const mockValuesForSetDate01 = {
monthIndex: 12,
monthString: "December-2023",
yearNum: 2023,
dates: [
datesInshop: [
{
dateNum: 1,
dayClasses: " current-day",
inputValue: "2023-12-01",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 2,
dayClasses: "",
inputValue: "2023-12-02",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 3,
dayClasses: " sunday",
inputValue: "2023-12-03",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 4,
dayClasses: "",
inputValue: "2023-12-04",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 5,
dayClasses: "",
inputValue: "2023-12-05",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 6,
dayClasses: "",
inputValue: "2023-12-06",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 7,
dayClasses: "",
inputValue: "2023-12-07",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 8,
dayClasses: "",
inputValue: "2023-12-08",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 9,
dayClasses: "",
inputValue: "2023-12-09",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 10,
dayClasses: " sunday",
inputValue: "2023-12-10",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 11,
dayClasses: "",
inputValue: "2023-12-11",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 12,
dayClasses: "",
inputValue: "2023-12-12",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 13,
dayClasses: "",
inputValue: "2023-12-13",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 14,
dayClasses: "",
inputValue: "2023-12-14",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 15,
dayClasses: "",
inputValue: "2023-12-15",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 16,
dayClasses: "",
inputValue: "2023-12-16",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 17,
dayClasses: " sunday",
inputValue: "2023-12-17",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 18,
dayClasses: "",
inputValue: "2023-12-18",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 19,
dayClasses: "",
inputValue: "2023-12-19",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 20,
dayClasses: "",
inputValue: "2023-12-20",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 21,
dayClasses: "",
inputValue: "2023-12-21",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 22,
dayClasses: "",
inputValue: "2023-12-22",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 23,
dayClasses: "",
inputValue: "2023-12-23",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 24,
dayClasses: " sunday",
inputValue: "2023-12-24",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 25,
dayClasses: "",
inputValue: "2023-12-25",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 26,
dayClasses: "",
inputValue: "2023-12-26",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 27,
dayClasses: "",
inputValue: "2023-12-27",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 28,
dayClasses: "",
inputValue: "2023-12-28",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 29,
dayClasses: "",
inputValue: "2023-12-29",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 30,
dayClasses: "",
inputValue: "2023-12-30",
isSelectable: true,
isAvailable: true,
},
{
dateNum: 31,
dayClasses: " sunday day-hidden",
inputValue: "2023-12-31",
isSelectable: false,
isAvailable: false,
},
],
datesMobile: [],
startDateDayIndex: 5,
monthClass: "",
isMonthThatHidesSomeDaysForInitialView: true,
@ -265,32 +283,33 @@ const mockValuesForSetDate01 = {
monthIndex: 1,
monthString: "January-2024",
yearNum: 2024,
dates: [
datesInshop: [
{
dateNum: 1,
dayClasses: "",
inputValue: "2024-01-01",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 2,
dayClasses: "",
inputValue: "2024-01-02",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 30,
dayClasses: "",
inputValue: "2024-01-30",
isSelectable: false,
isAvailable: false,
},
{
dateNum: 31,
dayClasses: "",
inputValue: "2024-01-31",
isSelectable: false,
isAvailable: false,
},
],
datesMobile: [],
startDateDayIndex: 1,
monthClass: " month-hidden",
},
@ -303,36 +322,70 @@ const mockInitialData = {
initialViewEndDate: "2023-08-19",
calendarViewDirection: "future",
initialShopTimeSlotsResponse: {
days: [
{
date: "2023-07-25",
timeSlots: [
{
id: "06747-01820-S-B*20295*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
{
id: "06747-01820-S-B*20295*730 AM",
startTime: "07:30",
endTime: "08:30",
offerPremium: false,
},
],
},
{
date: "2023-07-26",
timeSlots: [
{
id: "06747-01820-S-B*20296*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
inshopTimeSlotsData: {
days: [
{
date: "2023-07-25",
timeSlots: [
{
id: "06747-01820-S-B*20295*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
{
id: "06747-01820-S-B*20295*730 AM",
startTime: "07:30",
endTime: "08:30",
offerPremium: false,
},
],
},
{
date: "2023-07-26",
timeSlots: [
{
id: "06747-01820-S-B*20296*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
mobileTimeSlotsData: {
days: [
{
date: "2023-07-25",
timeSlots: [
{
id: "06747-01820-S-B*20295*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
{
id: "06747-01820-S-B*20295*730 AM",
startTime: "07:30",
endTime: "08:30",
offerPremium: false,
},
],
},
{
date: "2023-07-26",
timeSlots: [
{
id: "06747-01820-S-B*20296*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
},
@ -342,30 +395,59 @@ const mockInitialData = {
};
const mockCallbackResponse = {
days: [
{
date: "2024-12-30",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
{
date: "2024-01-02",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
inshopTimeSlotsData: {
days: [
{
date: "2024-12-30",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
{
date: "2024-01-02",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
mobileTimeSlotsData: {
days: [
{
date: "2024-12-30",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
{
date: "2024-01-02",
timeSlots: [
{
id: "06747-01820-S-B*20453*7 AM",
startTime: "07:00",
endTime: "08:00",
offerPremium: false,
},
],
},
],
},
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
};
@ -429,7 +511,7 @@ describe("date-picker.vue", () => {
wrapper.unmount();
});
test("If prop customSelectableDatesCallback is not supplied, expect the default function will return empty array", () => {
test("If prop getMoreDatesCallback is not supplied, expect the default function will return empty array", () => {
// Arrange
const { wrapper } = setupMocks({
propsData: {
@ -438,7 +520,7 @@ describe("date-picker.vue", () => {
});
// Act
const callbackResponse = wrapper.vm.customSelectableDatesCallback(
const callbackResponse = wrapper.vm.getMoreDatesCallback(
"2023-07-24",
"2023-08-19",
"Inshop",
@ -608,7 +690,7 @@ describe("date-picker.vue", () => {
const testResult = await wrapper.vm.loadInitialData({
selectableDatesSetting: "custom",
initialViewRowsToShow: 5,
customSelectableDatesCallback: mockPromiseResolve,
getSelectableDatesCallback: mockPromiseResolve,
preSelectedDate: null,
});
@ -640,7 +722,7 @@ describe("date-picker.vue", () => {
const testResult = await datePicker.methods.loadInitialData.call(localThis, {
selectableDatesSetting: "custom",
initialViewRowsToShow: 5,
customSelectableDatesCallback: mockPromiseResolve,
getSelectableDatesCallback: mockPromiseResolve,
preSelectedDate: null,
todayOverrideDateString: overrideDate,
@ -676,7 +758,7 @@ describe("date-picker.vue", () => {
const testResult = await datePicker.methods.loadInitialData.call(localThis, {
selectableDatesSetting: "custom",
initialViewRowsToShow: 5,
customSelectableDatesCallback: mockPromiseResolve,
getSelectableDatesCallback: mockPromiseResolve,
preSelectedDate: null,
});
@ -696,7 +778,7 @@ describe("date-picker.vue", () => {
const testResult = await wrapper.vm.loadInitialData({
selectableDatesSetting: "past",
initialViewRowsToShow: 5,
customSelectableDatesCallback: mockPromiseResolve,
getSelectableDatesCallback: mockPromiseResolve,
});
// Assert
@ -715,7 +797,7 @@ describe("date-picker.vue", () => {
const testResult = await wrapper.vm.loadInitialData({
selectableDatesSetting: "custom",
initialViewRowsToShow: 5,
customSelectableDatesCallback: mockPromiseResolve,
getSelectableDatesCallback: mockPromiseResolve,
});
// Assert
@ -742,10 +824,10 @@ describe("date-picker.vue", () => {
// Assert
expect(wrapper.vm.hideSomeDaysForInitialView).toBe(true);
expect(wrapper.vm.selectableDatesData[0]).toMatchObject({
expect(wrapper.vm.selectableDatesInshop[0]).toMatchObject({
date: "2023-12-30",
});
expect(wrapper.vm.selectableDatesData[0].timeSlots.length).toBeGreaterThan(0);
expect(wrapper.vm.selectableDatesInshop[0].timeSlots.length).toBeGreaterThan(0);
expect(wrapper.vm.months.length).toBe(7);
expect(wrapper.vm.months[0]).toMatchObject({
monthLabel: "December",
@ -992,7 +1074,7 @@ describe("date-picker.vue", () => {
const testResult = await datePicker.methods.loadInitialData.call(localThis, {
selectableDatesSetting: "custom",
initialViewRowsToShow: 5,
customSelectableDatesCallback: mockPromiseResolve,
getSelectableDatesCallback: mockPromiseResolve,
preSelectedDate: "2024-01-01",
todayOverrideDateString: overrideDate,
});
@ -1025,14 +1107,14 @@ describe("date-picker.vue", () => {
// Act
await wrapper.vm.showAnotherMonth();
// Assert
expect(wrapper.vm.updateSelectableDates).toHaveBeenCalledWith(
"2023-12-31",
"2023-12-31"
);
expect(wrapper.vm.scrollToElement).toHaveBeenCalledWith("December-2023");
// // Assert
// expect(wrapper.vm.updateSelectableDates).toHaveBeenCalledWith(
// "2023-12-31",
// "2023-12-31"
// );
// expect(wrapper.vm.scrollToElement).toHaveBeenCalledWith("December-2023");
wrapper.unmount();
// wrapper.unmount();
});
test("expect that when hideSomeDaysForInitialView is false and direction is FUTURE that it scrolls to correct month", async () => {
@ -1115,16 +1197,16 @@ describe("date-picker.vue", () => {
});
describe("updateSelectableDates method...", () => {
test("expect that new dates from callback are added to selectableDatesData", async () => {
test("expect that new dates from callback are added to selectableDatesInshop", async () => {
// Arrange
const { wrapper } = setupMocks({
propsData: {
selectableDatesSetting: "custom",
todayOverrideDateString: mockValuesForSetDate01.overrideDate,
customSelectableDatesCallback: jest.fn(() => mockCallbackResponse),
getMoreDatesCallback: jest.fn(() => mockCallbackResponse),
},
});
wrapper.vm.selectableDatesData = [
wrapper.vm.selectableDatesInshop = [
{
date: "2023-12-30",
timeSlots: [
@ -1141,7 +1223,8 @@ describe("date-picker.vue", () => {
// Act
await wrapper.vm.updateSelectableDates("2024-01-01", "2024-01-05");
const isFoundInArray = wrapper.vm.selectableDatesData.find((obj) => {
const isFoundInArray = wrapper.vm.selectableDatesInshop.find((obj) => {
return obj.date === "2024-01-02";
});
@ -1151,16 +1234,16 @@ describe("date-picker.vue", () => {
wrapper.unmount();
});
test("expect that isSelectable flag on selectable dates is set to true", async () => {
test("expect that isAvailable flag on selectable dates is set to true", async () => {
// Arrange
const { wrapper } = setupMocks({
propsData: {
selectableDatesSetting: "custom",
todayOverrideDateString: mockValuesForSetDate01.overrideDate,
customSelectableDatesCallback: jest.fn(() => mockCallbackResponse),
getMoreDatesCallback: jest.fn(() => mockCallbackResponse),
},
});
wrapper.vm.selectableDatesData = [
wrapper.vm.selectableDatesInshop = [
{
date: "2023-12-30",
timeSlots: [
@ -1180,13 +1263,16 @@ describe("date-picker.vue", () => {
const targetMonth = wrapper.vm.months.find((month) => {
return month.monthString === "January-2024";
});
const dateInArray = targetMonth.dates.find((date) => {
console.log("TEST targetMonth: ", targetMonth);
const dateInArray = targetMonth.datesInshop.find((date) => {
return date.inputValue === "2024-01-02";
});
// Assert
expect(dateInArray).toMatchObject({
isSelectable: true,
isAvailable: true,
});
wrapper.unmount();
@ -1194,7 +1280,7 @@ describe("date-picker.vue", () => {
});
describe("scrollToElement method...", () => {
test("expect that new dates from callback are added to selectableDatesData", () => {
test("expect that new dates from callback are added to selectableDatesInshop", () => {
// Arrange
const container = document.createElement("div");
container.classList.add("page-container-grouped-styles");

View file

@ -53,18 +53,19 @@
SAT
</div>
<div
v-for="date in month.dates"
v-show="!isMobileSelected"
v-for="date in month.datesInshop"
:key="date.inputValue"
:id="date.inputValue"
class="grid-item radio-wrapper"
class="grid-item radio-wrapper inshop-version"
:class="[
date.dateNum === 1 ? 'first-day-' + month.startDateDayIndex : '',
date.dayClasses,
date.isSelectable ? 'selectable-day' : '',
date.isAvailable ? 'selectable-day' : '',
date.isPricingByDayUpchargeDay ? 'upch-day' : '',
]">
<input
:disabled="!date.isSelectable"
:disabled="!date.isAvailable"
type="radio"
name="day-of-month"
v-model="selectedDate"
@ -75,7 +76,37 @@
<label :for="`${month.monthLabel}-${date.dateNum.toString()}`">
<span>{{ date.dateNum.toString() }}</span>
<span
v-if="isPricingByDayExperiment && showPricingByDay && date.isSelectable"
v-if="isPricingByDayExperiment && showPricingByDay && date.isAvailable"
class="price">
{{ date.priceString }}
</span>
</label>
</div>
<div
v-show="isMobileSelected"
v-for="date in month.datesMobile"
:key="`${date.inputValue}-mobile`"
:id="`${date.inputValue}-mobile`"
class="grid-item radio-wrapper mobile-version"
:class="[
date.dateNum === 1 ? 'first-day-' + month.startDateDayIndex : '',
date.dayClasses,
date.isAvailable ? 'selectable-day' : '',
date.isPricingByDayUpchargeDay ? 'upch-day' : '',
]">
<input
:disabled="!date.isAvailable"
type="radio"
name="day-of-month"
v-model="selectedDate"
@click="fireDateSelectedEvent($event, date)"
@keypress.enter="fireDateSelectedEvent($event, date)"
:value="`${date.inputValue}-mobile`"
:id="`${month.monthLabel}-${date.dateNum.toString()}-mobile`" />
<label :for="`${month.monthLabel}-${date.dateNum.toString()}-mobile`">
<span>{{ date.dateNum.toString() }}</span>
<span
v-if="isPricingByDayExperiment && showPricingByDay && date.isAvailable"
class="price">
{{ date.priceString }}
</span>
@ -134,21 +165,16 @@ import {
MONTHS_OF_YEAR,
DAYS_OF_WEEK,
} from "@/digital-components/date-picker/mixins/constants";
import {
AppointmentTypeStrings,
PREMIUM_FEE_PART_TYPE,
PRICING_BY_DAY_PART_TYPE,
} from "@/constants/schedule-constants";
import { PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import {
selectableDaysOptions,
requiredParameter,
forceTwoDigitString,
} from "@/digital-components/date-picker/mixins/helpers";
import {
convertDateToDateString,
convertDateStringToDate,
} from "@/layouts/schedule/helpers/schedule-helper";
import { useField, ErrorMessage, defineRule } from "vee-validate";
import { useField, ErrorMessage } from "vee-validate";
import { deepClone } from "@/helpers/object-helper";
import { v4 as uuidv4 } from "uuid";
@ -159,10 +185,10 @@ export default {
isLoading: true,
months: null,
disableViewMoreDatesButton: false,
selectableDatesData: [], // NOTE: uses monthNum (1-based), NOT monthIndex (0-based)
hideSomeDaysForInitialView: null,
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesInshop: [], // NOTE: this and the mobile version below use monthNum (1-based), NOT monthIndex (0-based)
selectableDatesMobile: [],
};
},
props: {
@ -182,7 +208,7 @@ export default {
type: String,
default: null,
},
customSelectableDatesCallback: {
getMoreDatesCallback: {
type: Function,
default() {
return [];
@ -202,6 +228,7 @@ export default {
estimatedServiceMinutesMinimum: Number,
estimatedServiceMinutesMaximum: Number,
displayWaitList: Boolean,
isMobileSelected: Boolean,
},
setup(props) {
const uuid = uuidv4();
@ -304,14 +331,16 @@ export default {
const date = new Date(dateStr.split("-")[0], parseInt(dateStr.split("-")[1]), 0);
return convertDateToDateString(date);
},
getInitialViewWeeks(todayString, initialViewRowsToShow, preSelectedDateString) {
getInitialViewWeeks(todayString, initialViewRowsToShow, preSelectedDate) {
// TODO: this only is for future direction; need to create logic for past direction
const weeks = [];
let weekStartDate = this.getWeekStartDate(todayString);
let weekEndDate = this.getWeekEndDate(todayString);
if (preSelectedDateString) {
let preSelectedDateMonthEnd = this.getMonthEnd(preSelectedDateString);
if (preSelectedDate) {
let preSelectedDateMonthEnd = this.getMonthEnd(
preSelectedDate.replace("-mobile", "")
);
let weekIncludesPreSelectedMonthEnd = false;
let i = 0;
while (!weekIncludesPreSelectedMonthEnd) {
@ -402,7 +431,7 @@ export default {
},
async loadInitialData(config) {
/*
** NOTE: this _could_ be called by a parent before fully loaded, so data or computeds might not be available
** NOTE: this _could_ be called by a parent before fully loaded, so FYI component data or computeds might not be available
*/
let todayDateString;
let calendarViewDirection = "none";
@ -421,7 +450,7 @@ export default {
const currentMonthEnd = this.getMonthEnd(todayDateString);
// TODO - set up currentMonthStart if direction is PAST:
// let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1);
// let currentMonthStart = new Date(todayYearNum, todayMonthIndex - 1, 1);
const initialViewWeeks = this.getInitialViewWeeks(
todayDateString,
config.initialViewRowsToShow,
@ -451,10 +480,9 @@ export default {
}
const loadInitialDataPromise = new Promise((resolve, reject) => {
const response = config.customSelectableDatesCallback(
const response = config.getSelectableDatesCallback(
initialViewStartDate,
initialViewEndDate,
store.getters.order.serviceLocation.appointmentType,
store.getters.order.serviceLocation.provider.providerNumber
);
resolve(response);
@ -480,9 +508,17 @@ export default {
const direction = config.calendarViewDirection;
const monthsAfterToLoadOffset = 6;
const monthsBeforeToLoadOffset = 36;
config.initialShopTimeSlotsResponse.days.forEach((selectableDate) => {
this.selectableDatesData.push(selectableDate);
});
config.initialShopTimeSlotsResponse.inshopTimeSlotsData.days.forEach(
(selectableDate) => {
this.selectableDatesInshop.push(selectableDate);
}
);
config.initialShopTimeSlotsResponse.mobileTimeSlotsData.days.forEach(
(selectableDate) => {
this.selectableDatesMobile.push(selectableDate);
}
);
// GENERATE MONTHS AND PUSH THEM INTO ARRAY
const months = [];
@ -534,7 +570,7 @@ export default {
}
},
async getMonthData(offset = requiredParameter(), options) {
/* options will contain:
/* options contains:
calendarViewDirection (string)
initialViewStartDateNum (number)
initialViewEndDateNum (number)
@ -543,9 +579,14 @@ export default {
hideSecondMonth (boolean),
preSelectedDate (string)
data used:
todayDate (date object)
data variables used:
this.todayString (date string)
this.currentWeekStartDateNum
this.currentWeekEndDateNum
this.todayDateNum
this.hideSomeDaysForInitialView (string)
this.selectableDatesInshop
this.selectableDatesMobile
*/
let monthNum = convertDateStringToDate(this.todayString).getMonth() + offset + 1;
@ -568,7 +609,8 @@ export default {
const preSelectedDateObj = options.preSelectedDate
? convertDateStringToDate(options.preSelectedDate)
: null;
const dates = [];
let datesInshop = [];
let datesMobile = [];
const monthEndDate = new Date(yearNum, monthNum, 0);
const monthStartDateNum =
offset === 0 && calendarViewDirection === "future"
@ -620,64 +662,74 @@ export default {
monthClass = monthClass + " last-available-month";
}
// populate dates array
for (let i = monthStartDateNum; i <= monthEndDateNum; i++) {
let dayClasses = "";
const dateString =
yearNum.toString() +
"-" +
("0" + monthNum).slice(-2) +
"-" +
("0" + i).slice(-2);
const dayIndex = convertDateStringToDate(dateString).getDay();
const dayObject = DAYS_OF_WEEK[dayIndex];
const isSelectable =
this.selectableDatesData.findIndex((date) => date.date === dateString) > -1
? true
: false;
const isPricingByDayUpchargeDay = dayObject.isPricingByDayUpchargeDay;
const displayPrice = isPricingByDayUpchargeDay
? options.pricingByDayBasePrice + options.pricingByDayUpcharge
: options.pricingByDayBasePrice;
const priceString = "$" + displayPrice;
const getDates = (selectableDates, datesArray) => {
// populate dates array
for (let i = monthStartDateNum; i <= monthEndDateNum; i++) {
let dayClasses = "";
const dateString =
yearNum.toString() +
"-" +
("0" + monthNum).slice(-2) +
"-" +
("0" + i).slice(-2);
const dayIndex = convertDateStringToDate(dateString).getDay();
const dayObject = DAYS_OF_WEEK[dayIndex];
const isAvailable =
selectableDates.findIndex((date) => date.date === dateString) > -1
? true
: false;
const isPricingByDayUpchargeDay = dayObject.isPricingByDayUpchargeDay;
const displayPrice = isPricingByDayUpchargeDay
? options.pricingByDayBasePrice + options.pricingByDayUpcharge
: options.pricingByDayBasePrice;
const priceString = "$" + displayPrice;
if (offset === 0 && i === this.todayDateNum) {
dayClasses += " current-day";
if (offset === 0 && i === this.todayDateNum) {
dayClasses += " current-day";
}
if (
offset === 0 &&
i < this.todayDateNum &&
calendarViewDirection === "future"
) {
dayClasses += " unavailable-day";
}
if (offset === 0 && i > this.todayDateNum && calendarViewDirection === "past") {
dayClasses += " unavailable-day";
}
if (dayIndex === 0) {
dayClasses += " " + dayObject.cssClass;
}
if (
this.hideSomeDaysForInitialView &&
convertDateStringToDate(initialViewEndDate).getMonth() + 1 === monthNum &&
convertDateStringToDate(initialViewEndDate).getDate() < i
) {
dayClasses += " day-hidden";
isMonthThatHidesSomeDaysForInitialView = true;
}
const dateObject = {
dateNum: i,
dayClasses: dayClasses,
inputValue: dateString,
priceString: priceString,
isAvailable: isAvailable,
isPricingByDayUpchargeDay: isPricingByDayUpchargeDay,
};
datesArray.push(dateObject);
}
if (offset === 0 && i < this.todayDateNum && calendarViewDirection === "future") {
dayClasses += " unavailable-day";
}
if (offset === 0 && i > this.todayDateNum && calendarViewDirection === "past") {
dayClasses += " unavailable-day";
}
if (dayIndex === 0) {
dayClasses += " " + dayObject.cssClass;
}
if (
this.hideSomeDaysForInitialView &&
convertDateStringToDate(initialViewEndDate).getMonth() + 1 === monthNum &&
convertDateStringToDate(initialViewEndDate).getDate() < i
) {
dayClasses += " day-hidden";
isMonthThatHidesSomeDaysForInitialView = true;
}
const dateObject = {
dateNum: i,
dayClasses: dayClasses,
inputValue: dateString,
priceString: priceString,
isSelectable: isSelectable,
isPricingByDayUpchargeDay: isPricingByDayUpchargeDay,
};
dates.push(dateObject);
}
return datesArray;
};
datesInshop = getDates(this.selectableDatesInshop, datesInshop);
datesMobile = getDates(this.selectableDatesMobile, datesMobile);
const monthToAdd = {
monthLabel: MONTHS_OF_YEAR[monthNum - 1],
monthIndex: monthNum,
monthString: MONTHS_OF_YEAR[monthNum - 1] + "-" + yearNum?.toString(),
yearNum: yearNum,
dates: dates,
datesInshop: datesInshop,
datesMobile: datesMobile,
startDateDayIndex: startDateDayIndex,
monthClass: monthClass,
isMonthThatHidesSomeDaysForInitialView: isMonthThatHidesSomeDaysForInitialView,
@ -697,9 +749,13 @@ export default {
);
// find the first day-hidden to become the next api call start date
monthStartDate = monthToShow.dates.find(({ dayClasses }) =>
dayClasses.includes("day-hidden")
);
monthStartDate = this.isMobileSelected
? monthToShow.datesMobile.find(({ dayClasses }) =>
dayClasses.includes("day-hidden")
)
: monthToShow.datesInshop.find(({ dayClasses }) =>
dayClasses.includes("day-hidden")
);
} else {
if (this.calendarViewDirection === "future") {
monthToShow = this.months.find((month) =>
@ -714,15 +770,19 @@ export default {
}
}
if (monthToShow) {
const monthToShowDates = this.isMobileSelected
? monthToShow.datesMobile
: monthToShow.datesInshop;
const monthStartDateString = monthStartDate
? monthStartDate.inputValue
: monthToShow.dates[0].inputValue;
: monthToShowDates[0].inputValue;
// make new API call with this month's start and end dates
const moreSelectableDatesData = await this.updateSelectableDates(
monthStartDateString,
monthToShow.dates[monthToShow.dates.length - 1].inputValue
monthToShowDates[monthToShowDates.length - 1].inputValue
);
this.isLoading = false;
this.hideSomeDaysForInitialView = false; // if hid days on initial partial view, this will reveal those days
monthToShow.monthClass = monthToShow.monthClass.replace(" month-hidden", "");
@ -735,27 +795,37 @@ export default {
}
},
async updateSelectableDates(monthStart, monthEnd) {
const moreSelectableDates = await this.customSelectableDatesCallback(
monthStart,
monthEnd,
this.$store.getters.order.serviceLocation.appointmentType,
this.$store.getters.order.serviceLocation.provider.providerNumber
);
const moreSelectableDates = await this.getMoreDatesCallback(monthStart, monthEnd);
moreSelectableDates.days.forEach((selectableDate) => {
const index = this.selectableDatesData.findIndex(
moreSelectableDates.inshopTimeSlotsData.days.forEach((selectableDate) => {
const index = this.selectableDatesInshop.findIndex(
(dateObj) => dateObj.date === selectableDate.date
);
if (index === -1) this.selectableDatesData.push(selectableDate);
if (index === -1) this.selectableDatesInshop.push(selectableDate);
this.months.forEach((month) => {
// TODO: avoid checking all calendar dates; maybe only ones between monthStart and monthEnd as defined above?
month.dates.forEach((date) => {
month.datesInshop.forEach((date) => {
if (date.inputValue === selectableDate.date) {
date["isSelectable"] = true;
date["isAvailable"] = true;
}
});
});
});
moreSelectableDates.mobileTimeSlotsData.days.forEach((selectableDate) => {
const index = this.selectableDatesMobile.findIndex(
(dateObj) => dateObj.date === selectableDate.date
);
if (index === -1) this.selectableDatesMobile.push(selectableDate);
this.months.forEach((month) => {
// TODO: avoid checking all calendar dates; maybe only ones between monthStart and monthEnd as defined above?
month.datesMobile.forEach((date) => {
if (date.inputValue === selectableDate.date) {
date["isAvailable"] = true;
}
});
});
});
return moreSelectableDates;
},
scrollToElement(elementId, duration = 800, easing = "ease-in-out") {
@ -913,8 +983,7 @@ export default {
.month-year {
grid-area: 1 / 1 / 2 / 5;
font-family: AvertaBold;
letter-spacing: 0.75px;
font-family: UrbanistBold;
color: $black;
justify-content: center;
margin-bottom: 0.5rem;
@ -1209,7 +1278,7 @@ export default {
border-radius: 0.25rem;
color: $black;
span {
font-family: AvertaSemibold;
font-family: UrbanistSemibold;
font-weight: 400;
}
}
@ -1240,7 +1309,7 @@ export default {
&.current-day {
label {
font-weight: 600;
font-family: "AvertaSemibold";
font-family: "UrbanistSemibold";
color: $black;
font-size-adjust: 0.5; // needed for Averta fonts baseline alignment
}
@ -1252,11 +1321,10 @@ export default {
text-decoration: none;
color: $black;
font-weight: 400;
font-family: "AvertaSemibold";
font-family: "UrbanistSemibold";
font-size-adjust: 0.5; // needed for Averta fonts baseline alignment
&.price {
font-family: "AvertaRegular";
font-weight: 400;
}
}
@ -1274,7 +1342,7 @@ export default {
color: $blue;
span {
font-weight: 400;
font-family: "AvertaSemibold";
font-family: "UrbanistSemibold";
}
}
}

View file

@ -136,8 +136,8 @@ export default {
<style lang="scss">
.dropdown-question {
label {
font-family: UrbanistSemibold;
color: $black;
font-weight: 500;
}
.label-bold {
color: $black;

View file

@ -129,7 +129,6 @@ export default {
<style lang="scss">
.dockableContainer {
&.showDockableContainer {
font-family: AvertaRegular;
color: $gray-600;
.sidebarBody {
button {

View file

@ -256,8 +256,8 @@ export default {
justify-content: center !important;
}
label {
font-family: UrbanistSemibold;
color: $black;
font-weight: 500;
}
.label-bold {
font-weight: 600;

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");
},
@ -177,13 +183,13 @@ export default {
p {
&.headline {
font-family: AvertaBold;
font-family: UrbanistBold;
color: $red;
font-weight: 600;
}
&.subheadline {
font-family: AvertaBold;
font-family: UrbanistBold;
font-weight: 600;
}
}
@ -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

@ -424,7 +424,6 @@ export default {
text-transform: uppercase;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.75px;
}
.special-save-box {
background-color: $green-100;

View file

@ -476,7 +476,6 @@ export default {
text-transform: uppercase;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.75px;
}
.special-save-box {
background-color: $green-100;

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 {
@ -1358,6 +1338,7 @@ export default {
visibility: visible;
}
a {
font-family: UrbanistSemibold;
text-decoration: none;
}
}

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

@ -99,9 +99,7 @@ export default {
lowerCaseParams.append(name.toLowerCase(), value);
}
const zip =
peekQueryFromStash(queryStrings.ZIP_CODE) ??
store.getters.order.serviceLocation.zipCode;
const zip = store.getters.order.serviceLocation.zipCode;
var vinByAddressPromise;
if (zip) {

View file

@ -118,8 +118,6 @@ import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store";
import vinPagesMixin from "@/mixins/vin-pages-mixin";
import { routeData } from "@/router/constants/routes";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
import { queryStrings } from "@/constants/query-strings";
// DEFINE VALIDATION RULES
defineRule("license-plate-required", required(errorMessages.LICENSE_PLATE_REQUIRED));
@ -164,8 +162,7 @@ export default {
data() {
return {
licensePlate: this.getLicensePlateFromStore(),
registrationZipCode:
this.getRegistrationZipFromStore() ?? consumeQueryFromStash(queryStrings.ZIP_CODE),
registrationZipCode: this.getRegistrationZipFromStore(),
emailOrSms: this.getEmailOrSmsFromStore(),
serviceZipCode: this.getServiceZipFromStore(),
carId: this.getCarIdfromStore(),

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

@ -4,7 +4,7 @@
<textBlock
:cmsWidgetName="headerCmsWidgetName"
:customText="customHeaderText"
typeStyle="body small bold dark"
typeStyle="body small dark"
marginTopSizeOverride="0" />
</div>
<div

View file

@ -145,6 +145,10 @@ export default {
}
}
:deep(.review-table .text-block span) {
font-family: UrbanistSemibold;
}
.review-toggle {
&.expanded {
margin-bottom: 0;
@ -177,8 +181,8 @@ export default {
text-decoration: none;
}
.label {
font-family: UrbanistSemibold;
color: $black;
font-weight: 500;
line-height: 1.625;
}
}

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,56 @@ 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;
&#extended-pay-option {
max-width: 28rem;
}
}
}
#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" />
@ -591,10 +591,7 @@ export default {
);
},
showAfterpayBanner() {
return (
(this.isAfterpayBreakoutDisplay || !this.isInsuranceSelected) &&
(!this.isRecalibrationOnOrder || !this.shouldHideRecalibration)
);
return !this.isRecalibrationOnOrder || !this.shouldHideRecalibration;
},
isRecalPriceRemove() {
return (
@ -606,14 +603,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

@ -405,7 +405,6 @@ export default {
text-transform: uppercase;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.75px;
}
.special-save-box {
background-color: $green-100;

View file

@ -12,7 +12,7 @@ import baseMixin from "../../mixins/base-mixin";
jest.mock("@/mixins/base-mixin.js", () => ({
methods: {
dispatchStoreAction: jest.fn().mockImplementation((storeAction) => {
if (storeAction === "getShopTimeSlots") {
if (storeAction === "getShopTimeSlots" || storeAction === "getMobileTimeSlots") {
return {
data: {
estimatedServiceMinutesMinimum: 90,
@ -71,7 +71,7 @@ jest.mock("@/mixins/base-mixin.js", () => ({
}
}),
dispatchStoreActionWithLogging: jest.fn().mockImplementation((storeAction) => {
if (storeAction === "getShopTimeSlots") {
if (storeAction === "getShopTimeSlots" || storeAction === "getMobileTimeSlots") {
return {
data: {
estimatedServiceMinutesMinimum: 90,
@ -243,48 +243,73 @@ describe("schedule.vue...", () => {
expect(arePagePrerequisitesValid).toBe(false);
});
test("should return newShopTimeSlots when getAvailableDatesMethod is called", async () => {
test("should return timeslots when getMoreScheduleData is called", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.selectableDatesMobile = {
days: [],
};
// Act
const newShopTimeSlots = await wrapper.vm.getAvailableDatesMethod(
const newShopTimeSlots = await wrapper.vm.getMoreScheduleData(
"2023-01-01",
"2023-01-31"
);
// Assert
expect(newShopTimeSlots).toStrictEqual({
days: [
{
date: "2023-12-01",
timeSlots: [
{
endTime: "08:00",
id: "06747-01820-S-B*20424*7 AM",
offerPremium: false,
startTime: "07:00",
},
],
},
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
inshopTimeSlotsData: {
days: [
{
date: "2023-12-01",
timeSlots: [
{
endTime: "08:00",
id: "06747-01820-S-B*20424*7 AM",
offerPremium: false,
startTime: "07:00",
},
],
},
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
},
mobileTimeSlotsData: {
days: [
{
date: "2023-12-01",
timeSlots: [
{
endTime: "08:00",
id: "06747-01820-S-B*20424*7 AM",
offerPremium: false,
startTime: "07:00",
},
],
},
],
estimatedServiceMinutesMinimum: 90,
estimatedServiceMinutesMaximum: 120,
},
});
});
test("should call API service in day ranges of 34 or less when getAvailableDatesMethod is called with large date ranges", async () => {
test("should call API service in day ranges of 34 or less when getMoreScheduleData is called with large date ranges", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.selectableDatesMobile = {
days: [],
};
// Act
await wrapper.vm.getAvailableDates.call(
await wrapper.vm.getMoreScheduleData.call(
wrapper.vm,
"2023-01-01",
"2023-03-31",
@ -293,7 +318,7 @@ describe("schedule.vue...", () => {
);
// Assert
expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledTimes(3);
expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledTimes(6);
expect(baseMixin.methods.dispatchStoreActionWithLogging).toHaveBeenCalledWith(
"getShopTimeSlots",
expect.anything(),
@ -306,7 +331,7 @@ describe("schedule.vue...", () => {
test("should call next() and call all functions within next", async () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.updateFooterButtonText = jest.fn();
@ -332,7 +357,7 @@ describe("schedule.vue...", () => {
})
);
expect(wrapper.vm.$refs.locationAlerts.initializeComponent).toHaveBeenCalled();
expect(wrapper.vm.selectableDatesData).toStrictEqual(
expect(wrapper.vm.selectableDatesInshop).toStrictEqual(
expect.objectContaining({
days: expect.any(Array),
estimatedServiceMinutesMaximum: expect.any(Number),
@ -353,7 +378,7 @@ describe("schedule.vue...", () => {
test("timeSlotsForSelectedDate should return timeslots if selected date is available", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [
{
date: "2022-11-11",
@ -392,7 +417,7 @@ describe("schedule.vue...", () => {
test("timeSlotsForSelectedDate should be null if no date has been selected", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [
{
date: "2022-11-11",
@ -430,7 +455,7 @@ describe("schedule.vue...", () => {
test("getServiceZipCtuCodeFromStore should return zipCodeCtu", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [],
};
@ -444,7 +469,7 @@ describe("schedule.vue...", () => {
test("getDisplayTextForMilitaryTime should return the correctly formatted string", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [],
};
const timeInput1 = "15:00";
@ -466,7 +491,7 @@ describe("schedule.vue...", () => {
test("Clicking back should fire correct navigation", () => {
// Arrange
const { wrapper } = setupMocks({});
wrapper.vm.selectableDatesData = {
wrapper.vm.selectableDatesInshop = {
days: [],
};
wrapper.vm.$router.navigateWithoutSaving = jest.fn();
@ -501,51 +526,6 @@ describe("schedule.vue...", () => {
expect(wrapper.vm.$router.navigateWithSaving).toHaveBeenCalled();
});
test("for mobile appts, updateSupportingItems should call store action to save supporting items", async () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Mobile";
store.getters.lineItems.supportingItems = [
{
partNumber: "EARLY BIRD",
description: null,
partType: "EARLY BIRD",
laborAmount: 0,
sellingPrice: 0,
kitPrice: 0,
},
];
const { wrapper } = setupMocks({});
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
};
});
wrapper.vm.mobilePremiumAppointmentFee = 14.99;
wrapper.setData({
selectedTimeSlot: {
date: "2019-01-01",
startTime: "09:00",
endTime: "10:00",
routeCode: null,
isPremiumAppointment: true,
},
});
// Act
await wrapper.vm.updateSupportingItems();
// Assert
expect(wrapper.vm.dispatchStoreAction).toBeCalledWith(
"saveSupportingItemsSuppressingStateResetting",
expect.arrayContaining([
expect.objectContaining({
partType: "EARLY BIRD",
}),
]),
expect.anything()
);
});
test("for Inshop appts, updateSupportingItems should call store action to save supporting items WITHOUT the EARLY BIRD supporting item", async () => {
// Arrange
store.getters.order.serviceLocation.appointmentType = "Inshop";

View file

@ -23,8 +23,9 @@
selectableDatesSetting="custom"
ref="datePicker"
v-model="selectedDate"
:isMobileSelected="isMobileSelected"
class="text-link-small"
:customSelectableDatesCallback="getAvailableDatesMethod"
:getMoreDatesCallback="getMoreScheduleData"
validationRules="date-required"
@date-clicked="handleDateClicked"
:pricingByDayBasePrice="pricingByDayBasePrice"
@ -34,12 +35,8 @@
:timeSlotsForSelectedDate="timeSlotsForSelectedDate"
:appointmentType="appointmentType"
:premiumAppointmentFee="mobilePremiumAppointmentFee"
:estimatedServiceMinutesMinimum="
selectableDatesData.estimatedServiceMinutesMinimum
"
:estimatedServiceMinutesMaximum="
selectableDatesData.estimatedServiceMinutesMaximum
"
:estimatedServiceMinutesMinimum="getServiceMinutesMin"
:estimatedServiceMinutesMaximum="getServiceMinutesMax"
@TimeSlotSelected="updateTimeSlot"
:displayWaitList="displayWaitList"
@waitListRequested="handleWaitListRequested" />
@ -67,8 +64,6 @@ 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";
@ -81,6 +76,7 @@ import {
} from "@/layouts/schedule/helpers/schedule-helper";
import {
AppointmentTypeStrings,
RouteCodeFlags,
PREMIUM_FEE_PART_TYPE,
PRICING_BY_DAY_PART_TYPE,
} from "@/constants/schedule-constants";
@ -101,24 +97,19 @@ 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 NUMBER_OF_CALENDAR_ROWS_TO_SHOW_FOR_INITIAL_VIEW = 2;
const getAvailableDates = async (
startDateString,
endDateString,
appointmentType,
providerNumber
) => {
const getScheduleApiResponse = async (startDateString, endDateString, 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;
let storeActionConfigInshop;
let storeActionConfigMobile;
if (i > 1) {
apiStartDate = sumDateString(apiEndDate, 1);
@ -133,29 +124,32 @@ const getAvailableDates = async (
}
}
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,
},
};
storeActionConfigMobile = {
storeAction: storeActions.GET_MOBILE_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
},
};
storeActionConfigInshop = {
storeAction: storeActions.GET_SHOP_TIME_SLOTS,
payload: {
startDate: apiStartDate,
endDate: apiEndDate,
shopAppointmentType: "InshopOrDropoff",
providerNumber: providerNumber,
},
};
if (apiStartDate < apiEndDate) {
storeActionConfigs.push(storeActionConfigInshop);
storeActionConfigs.push(storeActionConfigMobile);
}
if (apiStartDate < apiEndDate) storeActionConfigs.push(storeActionConfig);
}
const timeSlotsResponsesData = {
const inshopTimeSlotsData = {
days: [],
};
const mobileTimeSlotsData = {
days: [],
};
@ -167,39 +161,71 @@ const getAvailableDates = async (
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,
];
storeActionConfigs.map(async (storeActionConfig) => {
if (storeActionConfig.storeAction.indexOf("Mobile") > -1) {
const mobileTimeSlotsResponse =
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActionConfig.storeAction,
storeActionConfig.payload,
"schedule",
false
);
mobileTimeSlotsData.estimatedServiceMinutesMinimum =
mobileTimeSlotsResponse.data.estimatedServiceMinutesMinimum;
mobileTimeSlotsData.estimatedServiceMinutesMaximum =
mobileTimeSlotsResponse.data.estimatedServiceMinutesMaximum;
mobileTimeSlotsData.days = [
...mobileTimeSlotsData.days,
...mobileTimeSlotsResponse.data.days,
];
} else {
const inshopTimeSlotsResponse =
await baseMixin.methods.dispatchStoreActionWithLogging(
storeActionConfig.storeAction,
storeActionConfig.payload,
"schedule",
false
);
inshopTimeSlotsData.estimatedServiceMinutesMinimum =
inshopTimeSlotsResponse.data.estimatedServiceMinutesMinimum;
inshopTimeSlotsData.estimatedServiceMinutesMaximum =
inshopTimeSlotsResponse.data.estimatedServiceMinutesMaximum;
inshopTimeSlotsData.days = [
...inshopTimeSlotsData.days,
...inshopTimeSlotsResponse.data.days,
];
}
})
);
};
return makeParallelCalls().then(() => {
// sort days chronologically
timeSlotsResponsesData.days.sort(compareDayStrings);
return timeSlotsResponsesData;
inshopTimeSlotsData.days.sort(compareDayStrings);
mobileTimeSlotsData.days.sort(compareDayStrings);
return {
inshopTimeSlotsData: inshopTimeSlotsData,
mobileTimeSlotsData: mobileTimeSlotsData,
};
});
};
const returnFirstDate = (dates) => {
if (dates.days?.length > 0) {
return dates.days[0].date;
} else {
return null;
}
};
export default {
name: "schedule",
data() {
return {
selectedDate: this.getSelectedDate(),
selectedTimeSlotInfo: this.getSelectedTimeSlotInfo(),
selectableDatesData: [],
mobilePremiumAppointmentFee: null,
waitListRequested: null,
displayWaitList: null,
@ -209,6 +235,9 @@ export default {
pricingByDayBasePrice: null,
pricingByDayUpcharge: null,
showPricingByDay: null,
selectableDatesInshop: [],
selectableDatesMobile: [],
isMobileSelected: false,
};
},
async beforeRouteEnter(to, from, next) {
@ -230,7 +259,6 @@ export default {
isRecalibrationOnOrder && shouldHideRecalibration
? getItemsWithoutRecalParts(lineItems.glassParts)
: (lineItems.glassParts ?? []);
const supportingItemsFromStore = lineItems?.supportingItems;
const supportingItemsWithoutFees = baseMixin.methods.filterOutCertainPartTypesOrNumbers(
lineItems.supportingItems,
{
@ -249,10 +277,14 @@ export default {
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);
const isMobileSelected =
store.getters.order.serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE;
let includePricingByDayUpcharge = false;
// Check to see if date should be pre-selected
let preSelectedSlot = await store.getters.order.schedule;
let preSelectedDate = preSelectedSlot ? preSelectedSlot.date : null;
if (isMobileSelected && preSelectedDate) preSelectedDate += "-mobile";
if (!preSelectedSlot.date || preSelectedSlot?.date?.length < 1) {
preSelectedSlot = null;
} else {
@ -279,9 +311,9 @@ export default {
const datePickerInitialDataPromise = await datePicker.methods.loadInitialData({
// setup config options for date-picker
selectableDatesSetting: "custom",
initialViewRowsToShow: 2,
customSelectableDatesCallback: getAvailableDates,
preSelectedDate: preSelectedSlot ? preSelectedSlot.date : preSelectedSlot,
initialViewRowsToShow: NUMBER_OF_CALENDAR_ROWS_TO_SHOW_FOR_INITIAL_VIEW,
getSelectableDatesCallback: getScheduleApiResponse,
preSelectedDate: preSelectedDate,
});
// Get pricingByDayUpcharge needed for Pricing By Day
@ -351,7 +383,10 @@ export default {
vm.setCmsContent(resultMap.cmsContent);
vm.$refs.datePicker.initializeComponent(datePickerInitialData);
vm.$refs.locationAlerts.initializeComponent(resultMap.alertReasons);
vm.selectableDatesData = datePickerInitialData.initialShopTimeSlotsResponse;
vm.selectableDatesInshop =
datePickerInitialData.initialShopTimeSlotsResponse.inshopTimeSlotsData;
vm.selectableDatesMobile =
datePickerInitialData.initialShopTimeSlotsResponse.mobileTimeSlotsData;
vm.mobilePremiumAppointmentFee = resultMap.premiumFeeWithPrice
? resultMap.premiumFeeWithPrice[0]
: null;
@ -363,21 +398,33 @@ export default {
vm.pricingByDayBasePrice = pricingByDayBasePrice;
vm.pricingByDayUpcharge = pricingByDayUpcharge;
vm.showPricingByDay = showPricingByDay;
vm.isMobileSelected = isMobileSelected;
});
},
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;
// if no date is preselected on load, then select the first available
if (this.isMobileSelected) {
this.selectedDate = returnFirstDate(this.selectableDatesMobile);
} else {
this.selectedDate = returnFirstDate(this.selectableDatesInshop);
}
// if there is still no selected date, then load more and try again
if (!this.selectedDate) {
setTimeout(() => {
this.$refs.datePicker.showAnotherMonth().then((moreSelectableDatesData) => {
this.selectableDatesData = moreSelectableDatesData;
if (selectableDatesData?.days?.length > 0) {
this.selectedDate = selectableDatesData.days[0].date;
this.$refs.datePicker.showAnotherMonth().then((moreSelectableDates) => {
// update global
this.selectableDatesInshop.days =
moreSelectableDates.inshopTimeSlotsData.days;
this.selectableDatesMobile.days =
moreSelectableDates.mobileTimeSlotsData.days;
if (this.isMobileSelected) {
this.selectedDate = returnFirstDate(this.selectableDatesMobile);
} else {
this.selectedDate = returnFirstDate(this.selectableDatesInshop);
}
this.setDisplayWaitList();
@ -396,27 +443,46 @@ export default {
return this.splitCopyOnCMSPlaceHolder(this.ChangeShopLinkText);
},
appointmentType() {
return this.$store.getters.order.serviceLocation.appointmentType;
// TODO - THIS HAS TO BE CHANGED. APPOINTMENT TYPE CAN NOW CHANGE ON THE FLY, NOT FROM STORE ANYMORE.
// return this.$store.getters.order.serviceLocation.appointmentType;
if (this.isMobileSelected) {
return "Mobile";
} else {
return "InshopOrDropoff";
}
},
getServiceMinutesMin() {
return this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMinimum
: this.selectableDatesInshop.estimatedServiceMinutesMinimum;
},
getServiceMinutesMax() {
return this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMaximum
: this.selectableDatesInshop.estimatedServiceMinutesMaximum;
},
timeSlotsForSelectedDate() {
if (!this.selectedDate) return null;
const selectedDate = this.selectedDate;
return this.selectableDatesData.days?.find(
(selectableDate) => selectableDate.date === this.selectedDate
);
if (this.isMobileSelected) {
const selectedDateString = selectedDate.replace("-mobile", "");
return this.selectableDatesMobile.days?.find(
(selectableDate) => selectableDate.date === selectedDateString
);
} else {
return this.selectableDatesInshop.days?.find(
(selectableDate) => selectableDate.date === this.selectedDate
);
}
},
},
methods: {
splitCopyOnCMSPlaceHolder,
arePagePrerequisitesValid() {
const serviceLocation = store.getters.order.serviceLocation;
const serviceLocation = store.getters.order.serviceLocation; // TODO - MAY NEED TO REMOVE THIS AS A PREREQ
const serviceLocationPreReqs =
serviceLocation.zipCode &&
serviceLocation.zipCodeCtu &&
serviceLocation.appointmentType &&
(serviceLocation.appointmentType === AppointmentTypeStrings.MOBILE ||
serviceLocation.provider.providerNumber);
const serviceLocationPreReqs = serviceLocation.zipCode && serviceLocation.zipCodeCtu; // TODO - MAY NEED TO ADD IN SERVICE LOCATION PREREQS HERE TOO
const paymentInfo = store.getters.payment.isInsurance !== null;
@ -441,26 +507,38 @@ export default {
}
return preReqResult;
},
async getAvailableDatesMethod(startDate, endDate) {
const newShopTimeSlots = await getAvailableDates(
async getMoreScheduleData(startDate, endDate) {
// called only when "View more dates" is clicked; not on initial page load
const moreShopTimeSlots = await getScheduleApiResponse(
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
this.selectableDatesInshop.days = this.selectableDatesInshop.days.concat(
moreShopTimeSlots.inshopTimeSlotsData.days
);
return newShopTimeSlots;
this.selectableDatesMobile.days = this.selectableDatesMobile.days.concat(
moreShopTimeSlots.mobileTimeSlotsData.days
);
return moreShopTimeSlots;
},
getAvailableDates,
getScheduleApiResponse,
getServiceZipCtuCodeFromStore() {
return store.getters.order.serviceLocation.zipCodeCtu;
},
getSelectedDate() {
return store.getters.order.schedule.date;
let isMobileSelected = this.isMobileSelected;
if (isMobileSelected === undefined) {
isMobileSelected =
store.getters.order.serviceLocation.appointmentType ===
AppointmentTypeStrings.MOBILE;
}
let storedDate = store.getters.order.schedule.date;
if (isMobileSelected) storedDate += "-mobile";
return storedDate;
},
getSelectedTimeSlotInfo() {
const supportingItems = this.getSupportingItems();
@ -492,10 +570,14 @@ export default {
timeSlotInfo.timeSlot.date
)}`;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime
)}`;
if (this.appointmentType === AppointmentTypeStrings.IN_SHOP_OR_DROP_OFF) {
if (
!timeSlotInfo.timeSlot.routeCode.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)
) {
navbarButtonText += ` at ${this.getDisplayTextForMilitaryTime(
timeSlotInfo.timeSlot.startTime
)}`;
}
} else if (
this.appointmentType === AppointmentTypeStrings.MOBILE &&
!timeSlotInfo.isPremiumAppointment
@ -513,8 +595,7 @@ export default {
},
convertSelectedDateToShortMonthAndDay(selectedDate) {
// This conversion ensures we don't get get GMT induced date changes
const dateObject = convertDateStringToDate(selectedDate);
// Ex: April 25
const dateObject = convertDateStringToDate(selectedDate.replace("-mobile", ""));
return dateObject.toLocaleDateString("en-us", { month: "short", day: "numeric" });
},
getDisplayTextForMilitaryTime(militaryTimeInput, shouldTrimMinutesIfEmpty = false) {
@ -523,10 +604,7 @@ export default {
const minutes = militaryTimeInput.split(":")[1];
const meridianNotation = hours > 11 ? "PM" : "AM";
if (hours > 12) {
hours -= 12;
}
if (hours > 12) hours -= 12;
if (shouldTrimMinutesIfEmpty && minutes === "00") {
return `${hours} ${meridianNotation}`;
} else {
@ -540,16 +618,25 @@ export default {
);
},
forwardButtonAction() {
// TODO - WILL NEED TO SAVE THE APPOINTMENT TYPE ALSO (AND ANYTHING ELSE THAT SERVICE-LOCATION DID TOO)
this.updateSupportingItems();
if (this.displayWaitList) {
var gaLabel = "";
const status = this.waitListRequested ? "checked" : "unchecked";
let gaLabel = "";
let status = this.waitListRequested ? "checked" : "unchecked";
if (
this.waitListRequested === null &&
this.$store.state.order.customer.waitListRequested === true
) {
status = "checked";
}
const type = store.getters.isMobileAppointment ? "mobile" : "inshop";
gaLabel = `${status}_${type}`;
const currentDate = new Date();
const dateString = this.selectableDatesData.days[0].date;
const dateString = this.isMobileSelected
? this.selectableDatesMobile.days[0].date
: this.selectableDatesInshop.days[0].date;
const [year, month, day] = dateString.split("-").map(Number);
const appointmentDate = new Date(year, month - 1, day);
const timeDifference = appointmentDate - currentDate;
@ -566,11 +653,14 @@ export default {
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.selectedTimeSlotInfo.timeSlot.jobMinMinutes = this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMinimum?.toString()
: this.selectableDatesInshop.estimatedServiceMinutesMinimum?.toString();
this.selectedTimeSlotInfo.timeSlot.jobMaxMinutes = this.isMobileSelected
? this.selectableDatesMobile.estimatedServiceMinutesMaximum?.toString()
: this.selectableDatesInshop.estimatedServiceMinutesMaximum?.toString();
}
this.dispatchStoreAction(
@ -598,9 +688,12 @@ export default {
experimentSettings.DISPLAY_WAITLIST,
"true"
) &&
this.selectableDatesData.days[0]
this.selectableDatesInshop.days[0] && // TODO - ASK BUSINESS... WHAT LOGIC SHOULD WE USE HERE FOR WHEN TO DISPLAY THE WAIT LIST?
this.selectableDatesMobile.days[0]
) {
const dateString = this.selectableDatesData.days[0].date;
const dateString = this.isMobileSelected
? this.selectableDatesMobile.days[0].date
: this.selectableDatesInshop.days[0].date;
const [year, month, day] = dateString.split("-").map(Number);
const targetDate = new Date(year, month - 1, day);
const currentDate = new Date();
@ -766,7 +859,7 @@ export default {
}
}
.change-location a {
font-family: AvertaSemibold;
font-family: UrbanistSemibold;
}
.time-slots-question {
padding: 0 0.75rem;

View file

@ -631,7 +631,6 @@ export default {
margin-left: 0.5rem;
strong {
font-weight: 600;
letter-spacing: 0%;
}
}
.success-image {

View file

@ -14,7 +14,7 @@
class="mt-4"
:class="[
isMobileAppointment ? 'is-mobile-appointment' : '',
isDropOffAppointment ? 'is-drop-off-appointment' : '',
isInshopOrDropOffAppointment ? 'is-inshop-or-drop-off-appointment' : '',
]"
:answers="availableTimeSlots"
groupName="chooseTimeSlot"
@ -329,14 +329,12 @@ export default {
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);
return this.getAvailableTimeSlotsForInshopOrDropOff(
this.timeSlotsForSelectedDate.timeSlots
);
}
},
hasWaitListExperiment() {
@ -357,8 +355,8 @@ 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;
},
},
methods: {
@ -408,6 +406,26 @@ export default {
return availableTimeSlots;
},
getAvailableTimeSlotsForInshopOrDropOff(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
let buttonLabelValue;
if (timeSlot.id.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
buttonLabelValue = this.dropoffButtonText;
} else if (timeSlot.id.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
buttonLabelValue = this.overnightDropoffButtonText;
} else if (this.isSameDay) {
buttonLabelValue = this.sameDayDropoffButtonText;
} else {
buttonLabelValue = militaryToTwelveHourTime(timeSlot.startTime);
}
return {
value: timeSlot.id,
buttonLabel: buttonLabelValue,
};
});
return availableTimeSlots;
},
getAvailableTimeSlotsForMobile(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
const readableTime = `${militaryToTwelveHourTime(timeSlot.startTime)} - ${militaryToTwelveHourTime(
@ -561,7 +579,7 @@ export default {
margin-top: 1.5rem;
.time-slot-header {
font-family: AvertaBold;
font-family: UrbanistBold;
margin-top: 1.5rem;
}
.text-block.duration-text-block {

View file

@ -314,57 +314,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 +373,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 () => {
@ -1434,8 +1199,5 @@ 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();
return { wrapper, apiPromise };
}

View file

@ -90,24 +90,6 @@
</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" />
<button-question
v-if="selectedAppointmentType === 'Inshop'"
class="shop-question-button"
@ -121,7 +103,14 @@
: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"
@ -159,7 +148,6 @@
// 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";
@ -173,6 +161,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";
@ -331,20 +320,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) {
@ -387,6 +364,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;
@ -594,32 +585,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;
},
@ -668,15 +633,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);
},
@ -831,13 +787,49 @@ 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(
@ -917,16 +909,6 @@ 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 = this.shopProviderData.shopProviders[0];
}
@ -937,7 +919,6 @@ export default {
alert,
serviceZipModalQuestion,
appointmentTypeQuestion,
mobileLocationModalQuestions,
funnelHeader,
navbar,
funnelSubHeader,
@ -946,6 +927,7 @@ export default {
contentGroupModal,
shopQuestionPopup,
buttonQuestion,
textBlock,
},
};
</script>

View file

@ -96,7 +96,6 @@ import { saveSession } from "@/helpers/heritage-integration/order-helper.js";
import baseMixin from "@/mixins/base-mixin.js";
import { queryStrings } from "@/constants/query-strings";
import { routeData } from "@/router/constants/routes";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
// Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -116,7 +115,7 @@ export default {
mixins: [vinPagesMixin],
data() {
return {
serviceZipCode: this.getZipFromStore() ?? consumeQueryFromStash(queryStrings.ZIP_CODE),
serviceZipCode: this.getZipFromStore(),
emailOrSms: this.getEmailOrSmsFromStore(),
carId: this.getCarIdfromStore(),
isHeavyTruck: this.getIsVehicleHeavyTruckServiceableFromStore(),

View file

@ -110,10 +110,6 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { applicationConfig } from "../../constants/application-config";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import {
consumeQueryFromStash,
peekQueryFromStash,
} from "@/router/methods/helpers/querystring-stash";
import { debugLog } from "@/helpers/debug-log-helper";
//define validation rules
@ -138,7 +134,7 @@ export default {
imageUrl: this.getImagefromStore(),
imageVifNumber: this.getImageVifNumberfromStore(),
imageVifColor: this.getImageVifColorfromStore(),
serviceZipCode: this.getZipFromStore() ?? peekQueryFromStash(queryStrings.ZIP_CODE),
serviceZipCode: this.getZipFromStore(),
yearOptions: [],
makeOptions: [],
modelOptions: [],

View file

@ -159,8 +159,6 @@ import baseMixin from "@/mixins/base-mixin.js";
import store from "@/store";
import vinPagesMixin from "@/mixins/vin-pages-mixin";
import { routeData } from "@/router/constants/routes";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
import { queryStrings } from "@/constants/query-strings";
// DEFINE VALIDATION RULES
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
@ -204,7 +202,7 @@ export default {
data() {
return {
vin: this.getVinFromStore(),
serviceZipCode: this.getZipFromStore() ?? consumeQueryFromStash(queryStrings.ZIP_CODE),
serviceZipCode: this.getZipFromStore(),
emailOrSms: this.getEmailOrSmsFromStore(),
carId: this.getCarIdfromStore(),
isHeavyTruck: this.getIsVehicleHeavyTruckServiceableFromStore(),

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

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

@ -460,6 +460,10 @@ const routingTable = function () {
scenario: navigationScenarios.CLICKED_FORWARD,
destinationPageData: routeData.SCHEDULE,
},
{
scenario: navigationScenarios.CLICKED_FORWARD_WITH_MOBILE_SERVICE,
destinationPageData: routeData.MOBILE_DETAILS,
},
],
},
{
@ -479,6 +483,19 @@ const routingTable = function () {
},
],
},
{
pageName: routeData.MOBILE_DETAILS.name,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationPageData: routeData.SERVICE_LOCATION,
},
{
scenario: navigationScenarios.CLICKED_FORWARD,
destinationPageData: routeData.CUSTOMER_DETAILS,
},
],
},
{
pageName: routeData.CUSTOMER_DETAILS.name,
maps: [
@ -486,6 +503,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

@ -4,6 +4,10 @@ import { getFunnelCookie } from "@/helpers/heritage-integration/cookie-helper";
import { routeData } from "@/router/constants/routes";
import { isVerifiedInsurance } from "@/router/methods/helpers/requires-verified-redirect";
import store from "@/store";
import { queryStrings } from "@/constants/query-strings";
import baseMixin from "@/mixins/base-mixin";
import { storeActions } from "@/constants/store-actions";
import { consumeQueryFromStash } from "@/router/methods/helpers/querystring-stash";
export async function vehicleBeforeEnter(to, from) {
// If cookie indicates that vehicle has changed during insurance flow, reset parts info.
@ -12,6 +16,23 @@ export async function vehicleBeforeEnter(to, from) {
store.commit(storeMutations.RESET_GLASS_PARTS_STATE);
}
// If there is a zip querystring, consume & commit it here.
const newZipFromQuerystring = consumeQueryFromStash(queryStrings.ZIP_CODE);
if (newZipFromQuerystring) {
const zipData = await baseMixin.methods.getZipCodeData(
newZipFromQuerystring,
routeData.VEHICLE.name
);
if (zipData.isValid) {
store.dispatch(storeActions.SAVE_SERVICE_ZIP_CODE_INFO, {
zipCode: newZipFromQuerystring,
state: zipData.state.state,
zipCodeCtu: zipData.zipCodeCtu,
});
}
}
if (isVerifiedInsurance()) {
return {
name: routeData.VEHICLE_DAMAGE.name,

View file

@ -37,6 +37,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);
@ -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

@ -97,3 +97,11 @@ body {
}
}
}
button,
input,
select,
textarea,
option,
.btn {
letter-spacing: 0.03rem;
}

View file

@ -4,6 +4,7 @@ body {
font-size: 1rem;
line-height: 1.625;
font-weight: 400;
letter-spacing: 0.03rem;
}
//Headings
@ -43,7 +44,7 @@ h5,
h6,
.h6 {
line-height: 1.7;
letter-spacing: 0.75px;
letter-spacing: 0.03rem;
text-transform: uppercase;
}
@ -78,58 +79,58 @@ caption,
font-weight: 500;
}
// Averta Fonts
// Urbanist Fonts
@font-face {
font-family: "AvertaBold";
font-family: "UrbanistRegular";
src:
url("@/assets/fonts/averta-bold-webfont.woff2") format("woff2"),
url("@/assets/fonts/averta-bold-webfont.woff") format("woff");
url("@/assets/fonts/Urbanist-Regular.woff") format("woff2"),
url("@/assets/fonts/Urbanist-Regular.woff2") format("woff");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "AvertaBold_Italic";
font-family: "UrbanistSemibold";
src:
url("@/assets/fonts/averta-bolditalic-webfont.woff2") format("woff2"),
url("@/assets/fonts/averta-bolditalic-webfont.woff") format("woff");
url("@/assets/fonts/Urbanist-SemiBold.woff") format("woff2"),
url("@/assets/fonts/Urbanist-SemiBold.woff2") format("woff");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "AvertaExtraBold";
font-family: "UrbanistBold";
src:
url("@/assets/fonts/averta-extrabold-webfont.woff2") format("woff2"),
url("@/assets/fonts/averta-extrabold-webfont.woff") format("woff");
url("@/assets/fonts/Urbanist-Bold.woff") format("woff2"),
url("@/assets/fonts/Urbanist-Bold.woff2") format("woff");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "AvertaRegular";
font-family: "UrbanistExtraBold";
src:
url("@/assets/fonts/averta-regular-webfont.woff2") format("woff2"),
url("@/assets/fonts/averta-regular-webfont.woff") format("woff");
url("@/assets/fonts/Urbanist-ExtraBold.woff") format("woff2"),
url("@/assets/fonts/Urbanist-ExtraBold.woff2") format("woff");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "AvertaRegular_Italic";
font-family: "UrbanistMedium_Italic";
src:
url("@/assets/fonts/averta-regularitalic-webfont.woff2") format("woff2"),
url("@/assets/fonts/averta-regularitalic-webfont.woff") format("woff");
url("@/assets/fonts/Urbanist-MediumItalic.woff") format("woff2"),
url("@/assets/fonts/Urbanist-MediumItalic.woff2") format("woff");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "AvertaSemibold";
font-family: "UrbanistBold_Italic";
src:
url("@/assets/fonts/averta-semibold-webfont.woff2") format("woff2"),
url("@/assets/fonts/averta-semibold-webfont.woff") format("woff");
url("@/assets/fonts/Urbanist-BoldItalic.woff") format("woff2"),
url("@/assets/fonts/Urbanist-BoldItalic.woff2") format("woff");
font-weight: normal;
font-style: normal;
}

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
@ -120,14 +122,17 @@ $theme-colors: (
$body-color: $gray-600;
//Fonts
$font-family-sans-serif: AvertaRegular, Arial, Helvetica, sans-serif;
$font-family-sans-serif: UrbanistRegular, Arial, Helvetica, sans-serif;
$font-family-monospace:
SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
UrbanistRegular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
// stylelint-enable value-keyword-case
$font-family-base: $font-family-sans-serif;
$font-family-code: $font-family-monospace;
$font-size-base: 1rem; // Assumes the browser default, typically `16px`
$font-size-12: $font-size-base * 0.75; // 12px
$font-size-20: $font-size-base * 1.25; // 20px
//Custom Font size (extra small)
$font-size-xsm: $font-size-base * 0.75;
$font-sizes: (

View file

@ -103,6 +103,7 @@ export default {
justify-content: flex-start;
p {
font-family: UrbanistRegular;
color: $gray-600;
text-align: center;
@ -123,15 +124,16 @@ export default {
&:checked + .list-card-content {
p {
color: $black;
font-weight: 500;
font-family: UrbanistSemibold;
}
.p-bold {
font-family: UrbanistSemibold;
font-weight: 600;
}
.sub-copy {
font-family: UrbanistSemibold;
color: $gray-600;
font-weight: 400;
}
}