Merge pull request #1197 from Safelite/rlsmerge/develop-to-submit-cash-30-June

Rlsmerge/develop to submit-cash 30 june
This commit is contained in:
hiteshkumar87 2023-06-30 20:16:07 +05:30 committed by GitHub
commit e230fe765a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 20123 additions and 510 deletions

View file

@ -8,4 +8,9 @@ const PREMIUM_TIME_SLOT_ID_FLAG = "-premium";
const PREMIUM_FEE_PART_TYPE = "EARLY BIRD";
export { AppointmentTypeStrings, PREMIUM_TIME_SLOT_ID_FLAG, PREMIUM_FEE_PART_TYPE };
const RouteCodeFlags = {
ALL_DAY_DROP_OFF: "ALL DAY DROP OFF",
OVERNIGHT_DROP_OFF: "OVERNIGHT DROP OFF",
};
export { AppointmentTypeStrings, PREMIUM_TIME_SLOT_ID_FLAG, PREMIUM_FEE_PART_TYPE, RouteCodeFlags };

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,7 @@ const storeActions = {
RESET_DAMAGE_STATE_AND_DEPENDENCIES: "resetDamageAndDependencies",
RESET_REGISTRATION_STATE_AND_DEPENDENCIES: "resetRegistrationAndDependencies",
RESET_PARTS_STATE_AND_DEPENDENCIES: "resetPartsAndDependencies",
RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES: "resetServiceLocationAndDependencies",
RESET_STATE: "resetState",
// SAVE COMPONENT STATE

View file

@ -32,6 +32,7 @@ const storeMutations = {
UPDATE_REGISTRATION_LAST_NAME: "updateRegistrationLastName",
UPDATE_REGISTRATION: "updateRegistration",
UPDATE_SERVICE_ZIP: "updateServiceZip",
UPDATE_SERVICE_LOCATION: "updateServiceLocation",
UPDATE_SCHEDULE: "updateSchedule",

View file

@ -1,9 +1,6 @@
import { shallowMount, mount } from "@vue/test-utils";
import buttonQuestion from "./button-question";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import crypto from "crypto";
global.crypto = crypto;
describe("buttonQuestion.vue", () => {
it("Should show overflow classes on fieldset if isOverflowScrollable is true", () => {

View file

@ -112,7 +112,7 @@ export default {
computed: {
today() {
if (this.todayOverrideDateString) {
return new Date(this.todayOverrideDateString);
return new Date(this.todayOverrideDateString + "T00:00:00");
}
return new Date();
},
@ -151,6 +151,9 @@ export default {
},
},
methods: {
initializeComponent(initialData) {
this.setCalendarData(initialData);
},
fireDateClickedEvent() {
this.$emit("date-clicked");
},
@ -177,9 +180,12 @@ export default {
nextSunday.setDate(date.getDate() + daysUntilNextSunday);
return nextSunday;
},
getInitialViewWeeks(today, initialViewRowsToShow) {
getInitialViewWeeks(today, initialViewRowsToShow, preSelectedDateString) {
// TODO: this only is for future direction; need to create logic for past direction
const weeks = [];
if (preSelectedDateString) initialViewRowsToShow = 26;
let weekStartDate = this.getWeekStartDate(today);
let weekEndDate = this.getWeekEndDate(today);
for (let i = 0; i < initialViewRowsToShow; i++) {
@ -192,15 +198,23 @@ export default {
weekStartDate: weekStartDate,
weekEndDate: weekEndDate,
});
if (
preSelectedDateString &&
new Date(preSelectedDateString + "T00:00:00") < weekEndDate
) {
break;
}
}
// are any of these weeks split between two months?
// NOTE: a week split between two months counts as 2 weeks
const hasSplitWeek = (week) => {
return week.weekStartDate.getMonth() !== week.weekEndDate.getMonth() ? true : false;
};
const splitWeekIndex = weeks.findIndex(hasSplitWeek);
if (splitWeekIndex > -1) {
if (!preSelectedDateString && splitWeekIndex > -1) {
// a preSelectedDateString precludes split week logic
const week1 = [];
const week2 = [];
let switchToWeek2 = false;
@ -245,7 +259,7 @@ export default {
if (this.today) {
todayDate = this.today;
} else if (config.todayOverrideDateString) {
todayDate = new Date(config.todayOverrideDateString);
todayDate = new Date(config.todayOverrideDateString + "T00:00:00");
} else {
todayDate = new Date();
}
@ -262,20 +276,19 @@ export default {
const initialViewWeeks = this.getInitialViewWeeks(
todayDate,
config.initialViewRowsToShow
config.initialViewRowsToShow,
config.preSelectedDate
);
const initialViewStartDate = todayDate;
const initialViewEndDate = initialViewWeeks[initialViewWeeks.length - 1].weekEndDate;
const firstSaturdayMonth = initialViewWeeks[0].weekEndDate.getMonth();
const lastSundayMonth =
initialViewWeeks[initialViewWeeks.length - 1].weekStartDate.getMonth();
let hideSomeDaysForInitialView = false;
let hideSecondMonth = false;
// TODO - NEED TO CREATE OPPOSITE LOGIC FOR "past" DIRECTION LIKE BELOW vvvvv
if (calendarViewDirection === "future") {
// TODO - NEED TO CREATE OPPOSITE LOGIC FOR "past" DIRECTION LIKE BELOW v v v
if (calendarViewDirection === "future" && !config.preSelectedDate) {
if (firstSaturdayMonth !== lastSundayMonth) {
hideSomeDaysForInitialView = true;
}
@ -288,7 +301,7 @@ export default {
}
}
const myPromise = new Promise((resolve, reject) => {
const loadInitialDataPromise = new Promise((resolve, reject) => {
const response = config.customSelectableDatesCallback(
initialViewStartDate.toISOString().split("T")[0],
initialViewEndDate.toISOString().split("T")[0],
@ -298,7 +311,7 @@ export default {
resolve(response);
});
return myPromise.then((response) => {
return loadInitialDataPromise.then((response) => {
const initialData = {
todayDate: todayDate,
initialViewStartDate: initialViewStartDate,
@ -307,50 +320,11 @@ export default {
initialShopTimeSlotsResponse: response,
hideSomeDaysForInitialView: hideSomeDaysForInitialView,
hideSecondMonth: hideSecondMonth,
preSelectedDate: config.preSelectedDate,
};
return initialData;
});
},
initializeComponent(initialData) {
this.setCalendarData(initialData);
},
scrollToElement(elementId, speed, easing) {
// TODO - needs to be cleaned up & refactored
function scrollTopSmooth(wrapper, target, duration = 300, timingName = "linear") {
const initY = wrapper.scrollTop;
const wrapperRect = wrapper.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const targetY = targetRect.top - wrapperRect.top - BUFFER_OFFSET;
const timingFunc = TIMINGFUNC_MAP[timingName];
let start = null;
const step = (timestamp) => {
start = start || timestamp;
const progress = timestamp - start,
// Growing from 0 to 1
time = Math.min(1, (timestamp - start) / duration);
const percentageNew = timingFunc(time);
const distanceToGo = targetY;
const thisDistance = percentageNew * distanceToGo;
wrapper.scrollTo(0, initY + thisDistance);
if (percentageNew < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
}
const wrapper = this.$refs.datePickerFieldset;
const targetMonth = document.getElementById(elementId);
scrollTopSmooth(wrapper, targetMonth, 800, "ease-in-out");
},
async setCalendarData(config = {}) {
this.hideSomeDaysForInitialView = config.hideSomeDaysForInitialView;
const hideSecondMonth = config.hideSecondMonth;
@ -370,6 +344,7 @@ export default {
initialViewStartDate: config.initialViewStartDate,
initialViewEndDate: config.initialViewEndDate,
hideSecondMonth: hideSecondMonth,
preSelectedDate: config.preSelectedDate,
};
if (direction === "future") {
// first 0, then 1
@ -389,8 +364,17 @@ export default {
}
this.months = months;
this.isLoading = false;
},
if (config.preSelectedDate) {
this.$nextTick(() => {
//Advance to month
const monthToShow = this.months.find((month) =>
month.monthClass.includes("month-preselected")
);
this.scrollToElement(monthToShow.monthString);
});
}
},
async getMonthData(offset = requiredParameter(), options) {
/* options will contain:
calendarViewDirection (string)
@ -399,6 +383,7 @@ export default {
monthsBeforeToLoadOffset (number),
monthsAfterToLoadOffset (number),
hideSecondMonth (boolean),
preSelectedDate (string)
data used:
todayDate (date object)
@ -410,7 +395,10 @@ export default {
const calendarViewDirection = options.calendarViewDirection;
const initialViewStartDate = options.initialViewStartDate;
const initialViewEndDate = options.initialViewEndDate;
const hideSecondMonth = options.hideSecondMonth; // <<<<<<<<<<<<<
const hideSecondMonth = options.hideSecondMonth;
const preSelectedDateObj = options.preSelectedDate
? new Date(options.preSelectedDate + "T00:00:00")
: null;
const dates = [];
let monthClass = "";
let isMonthThatHidesSomeDaysForInitialView;
@ -447,11 +435,23 @@ export default {
const startDateDayIndex = monthStartDate.getDay();
const endDateDayIndex = monthEndDate.getDay();
if (Math.abs(offset) === 1 && hideSecondMonth) {
monthClass = monthClass + " month-hidden";
} else if (Math.abs(offset) > 1) {
monthClass = monthClass + " month-hidden";
if (preSelectedDateObj) {
if (
monthStartDate.getFullYear() === preSelectedDateObj.getFullYear() &&
monthStartDate.getMonth() === preSelectedDateObj.getMonth()
) {
monthClass = monthClass + " month-preselected";
} else if (monthStartDate > preSelectedDateObj) {
monthClass = monthClass + " month-hidden";
}
} else {
if (Math.abs(offset) === 1 && hideSecondMonth) {
monthClass = monthClass + " month-hidden";
} else if (Math.abs(offset) > 1) {
monthClass = monthClass + " month-hidden";
}
}
if (
Math.abs(offset) === options.monthsAfterToLoadOffset &&
calendarViewDirection === "future"
@ -581,6 +581,38 @@ export default {
});
});
},
scrollToElement(elementId, speed, easing) {
// TODO - needs to be cleaned up & refactored
function scrollTopSmooth(wrapper, target, duration = 300, timingName = "linear") {
const initY = wrapper.scrollTop;
const wrapperRect = wrapper.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const targetY = targetRect.top - wrapperRect.top - BUFFER_OFFSET;
const timingFunc = TIMINGFUNC_MAP[timingName];
let start = null;
const step = (timestamp) => {
start = start || timestamp;
const progress = timestamp - start,
// Growing from 0 to 1
time = Math.min(1, (timestamp - start) / duration);
const percentageNew = timingFunc(time);
const distanceToGo = targetY;
const thisDistance = percentageNew * distanceToGo;
wrapper.scrollTo(0, initY + thisDistance);
if (percentageNew < 1) {
window.requestAnimationFrame(step);
}
};
window.requestAnimationFrame(step);
}
const wrapper = document.getElementById("date-picker-fieldset");
const targetMonth = document.getElementById(elementId);
scrollTopSmooth(wrapper, targetMonth, 800, "ease-in-out");
},
},
components: {
loader,

View file

@ -1,6 +1,5 @@
import { shallowMount } from "@vue/test-utils";
import dropdownQuestion from "./dropdown-question";
import crypto from "crypto";
// Mock CMS content
const questionText = "Question Text";
@ -12,8 +11,6 @@ const mockMixin = {
},
};
global.crypto = crypto;
// TODO: Remove the following from dropdown-question.vue -> :class="(errors && errors.length) || hasError ? 'has-error' : ''"
// It is not being used.
describe("dropdownQuestion.vue", () => {

View file

@ -30,28 +30,26 @@
<script>
import { useField } from "vee-validate";
import { v4 as uuidv4 } from "uuid";
export default {
name: "dropdown-question",
props: {
modelValue: String,
customInputId: String,
customDropdownId: String,
options: {
type: Object,
required: true,
},
modelValue: String,
isDisabled: Boolean,
isRequired: Boolean,
validationRules: String,
cmsWidgetName: String,
hasError: Boolean,
placeHolderText: String,
customDropdownId: String,
},
setup(props) {
const dropdownId = !props.customDropdownId
? `dropdown-${crypto.randomUUID()}`
: props.customDropdownId;
const uuid = uuidv4();
const dropdownId = !props.customDropdownId ? `dropdown-${uuid}` : props.customDropdownId;
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;

View file

@ -7,15 +7,13 @@ const mockMeta = (returnValue) => jest.fn(async () => Promise.resolve(returnValu
import { shallowMount } from "@vue/test-utils";
import modal from "./modal";
import crypto from "crypto";
import { useForm } from "vee-validate";
import { Modal } from "bootstrap";
const footerButtonText = "Sample footer text here.";
const headerText = "Sample header text here.";
global.crypto = crypto;
describe("modal.vue", () => {
it("Should display modal header text when headerText is defined", async () => {
// Arrange / Act

View file

@ -44,6 +44,7 @@
import modalButtonMain from "@/digital-components/modal/ux-components/modal-button-main/modal-button-main";
import { Modal } from "bootstrap";
import { useForm } from "vee-validate";
import { v4 as uuidv4 } from "uuid";
export default {
name: "modal",
@ -58,7 +59,9 @@ export default {
},
},
setup() {
const modalId = `modal-${crypto.randomUUID()}`;
const uuid = uuidv4();
const modalId = `modal-${uuid}`;
const { meta, validate, resetForm } = useForm();
return {
@ -90,10 +93,12 @@ export default {
openModal() {
const modal = Modal.getOrCreateInstance(document.getElementById(this.modalId));
modal.show();
this.$emit("isModalOpened", true);
},
closeModal() {
const modal = Modal.getInstance(document.getElementById(this.modalId));
modal.hide();
this.$emit("isModalOpened", false);
},
},
computed: {

View file

@ -16,8 +16,7 @@
:text="getRouterLinkDisplayTextFromCopy(copy)"
href="#!"
@click-event="$emit('textLinkClicked', getRouterLinkRouteFromCopy(copy))"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
aria-label="Modal window" />
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)" />
</span>
<span v-else v-html="copy"></span>
</span>

View file

@ -1,6 +1,5 @@
import { shallowMount } from "@vue/test-utils";
import textboxQuestion from "./textbox-question";
import crypto from "crypto";
// Mock CMS content
const questionText = "Question Text";
@ -13,8 +12,6 @@ const mockMixin = {
};
const maska = jest.fn();
global.crypto = crypto;
describe("textboxQuestion.vue", () => {
it("Should render a text input", async () => {
// Arrange

View file

@ -78,13 +78,14 @@
<script>
import { useField, validate } from "vee-validate";
import { storeActions } from "@/constants/store-actions";
import loader from "@/ux-components/loader/loader.vue";
import { ref } from "vue";
import { v4 as uuidv4 } from "uuid";
export default {
name: "textbox-question",
props: {
customInputId: String,
type: {
type: String,
default: "text",
@ -98,7 +99,6 @@ export default {
default: true,
},
modelValue: String,
customInputId: String,
isDisabled: Boolean,
isRequired: Boolean,
hasIcon: Boolean, // If input has an icon
@ -122,7 +122,8 @@ export default {
keyDownHandler: Function,
},
setup(props) {
const inputId = !props.customInputId ? `input-${crypto.randomUUID()}` : props.customInputId;
const uuid = uuidv4();
const inputId = !props.customInputId ? `input-${uuid}` : props.customInputId;
const propsClone = Object.assign({}, props);
const modelValue = propsClone.modelValue;

View file

@ -1,5 +1,5 @@
// Components
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import addressQuestions from "@/fmg-components/address-questions/address-questions";
import alert from "@/ux-components/alert/alert";
// Supporting Files
@ -7,7 +7,6 @@ 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";
import { createImportSpecifier } from "typescript";
let autocompleteElement;
describe("address-questions.vue", () => {
@ -134,22 +133,19 @@ describe("address-questions.vue", () => {
});
test("Should set this.showAddressFields to true when the model is prepopulated", async () => {
// Arrange
// Act
const newAddressModel = {
streetAddress: "foo",
city: "foo",
state: "foo",
zipCode: "55555",
};
const wrapper = shallowMount(addressQuestions, {
propsData: {
modelValue: newAddressModel,
// Arrange / Act
const { wrapper } = setupMocks({
props: {
modelValue: {
streetAddress: "foo",
city: "foo",
state: "foo",
zipCode: "55555",
},
},
});
// Act
wrapper.vm.setupAddressLookup();
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.showAddressFields).toBe(true);

View file

@ -1,6 +1,6 @@
<template>
<div class="address-questions" role="application">
<div class="row mb-4">
<div class="address-questions">
<div class="row mb-4" aria-live="polite">
<div class="col">
<textboxQuestion
id="streetAddressField"
@ -122,14 +122,16 @@ export default {
},
data() {
return {
autocomplete: null,
autocompleteListener: null,
showAddressFields: false,
matchFound: null, // null = no attempted match, true = match was found, false = match was not found
displayVerificationWarning: false,
displayNoMatchWarning: false,
alertHeadlineVerificationWarning: "",
alertCopyVerificationWarning: "",
alertHeadlineNoMatchWarning: "",
alertCopyNoMatchWarning: "",
matchFound: null, // null = no attempted match, true = match was found, false = match was not found
};
},
computed: {
@ -203,182 +205,202 @@ export default {
return this.captureApartmentNumberOrBusinessName;
},
},
addressField1: {
get: function () {
return document.getElementById("autocomplete");
},
},
},
methods: {
setupAddressLookup() {
this.showAddressFields = false;
if (
this.addressModel.streetAddress &&
this.addressModel.city &&
this.addressModel.state &&
this.addressModel.zipCode
) {
this.showAddressFields = true;
return;
}
const addressField1 = document.getElementById("autocomplete");
const self = this;
loadGooglePlacesAutocompleteScript() {
// Load the Google Places Autocomplete script
const apiKey = applicationConfig.GOOGLE_PLACES_API_KEY;
this.$loadScript(
`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places&callback=Function.prototype`
)
.then(() => {
// Script is loaded, initialize the autocomplete textbox
const autocomplete = new window.google.maps.places.Autocomplete(addressField1, {
componentRestrictions: { country: ["us"] },
fields: ["address_components"],
types: ["geocode"],
});
).then(() => {
// When loaded, trigger the setup
this.initializeAutocomplete();
});
},
initializeAutocomplete() {
// Initialize the Google Places Autocomplete
this.autocomplete = new window.google.maps.places.Autocomplete(this.addressField1, {
componentRestrictions: { country: ["us"] },
fields: ["address_components"],
types: ["geocode"],
});
// Standard place_changed event handling
const autocompleteListener = window.google.maps.event.addListener(
autocomplete,
"place_changed",
fillInAddress
// Set up the Autocomplete place_changed event to call our method to fill in the address
this.autocompleteListener = window.google.maps.event.addListener(
this.autocomplete,
"place_changed",
this.fillInAddress
);
// When the Street Address textbox receives focus,
// append the search results list container to the bottom of the textbox
// and disable browser autofill
this.addressField1.addEventListener("focus", (e) => {
// Make place results box stick to the input on scroll
const streetAddressField = document.getElementById("streetAddressField");
const autocompleteResultsContainer =
document.getElementsByClassName("pac-container")[0];
if (autocompleteResultsContainer) {
streetAddressField.appendChild(autocompleteResultsContainer);
}
// Unfortunately this is the only place we can set the autocomplete attribute without the
// Google Places object resetting it to "off" which does nothing to prevent browser autofill
this.addressField1.setAttribute("autocomplete", "do-not-autofill");
});
this.addressField1.addEventListener("keydown", (e) => {
// If a match has been previously attempted then do nothing
if (this.matchFound !== null) {
return;
}
const event = new Event("place_changed");
// When either of the two enter keys or the tab key are pressed
if (e.code === "Enter" || e.code === "NumpadEnter" || e.code === "Tab") {
// Grab the selected item
const selectedItem = document.querySelector(
".pac-container .pac-item-selected"
);
addressField1.addEventListener("focus", () => {
// Wrapping the addressField1 element in the Google Address Autocomplete object
// will cause "autocomplete='off'" which Chrome completely ignores. This event
// handler will set the value to something arbitrary so autofill doesn't work.
// https://stackoverflow.com/a/30976223
addressField1.setAttribute("autocomplete", "do-not-autofill");
if (selectedItem !== null) {
// If an item was selected then fill in the address with the selected item
// by triggering the "place_changed" event of the Autocomplete object
this.autocomplete.dispatchEvent(event);
} else {
// Otherwise fill-in the address using first item from the list.
this.fillInAddressUsingFirstItem();
}
} else {
return;
}
});
// Make place results box stick to the input on scroll
const streetAddressField = document.getElementById("streetAddressField");
const autocompleteResultsContainer =
document.getElementsByClassName("pac-container")[0];
if (autocompleteResultsContainer) {
streetAddressField.appendChild(autocompleteResultsContainer);
}
});
this.addressField1.addEventListener("change", () => {
// If a match has been previously attempted then do nothing
if (this.matchFound !== null) {
return;
}
addressField1.addEventListener("keydown", (e) => {
const autocomplete = document.getElementById("autocomplete");
const event = new Event("place_changed");
// Get the address that the user clicked on (if any)
const clickedAddress = document.querySelector(".pac-container .pac-item:hover");
if (e.code === "Enter" || e.code === "NumpadEnter" || e.code === "Tab") {
const selectedItem = document.querySelector(
".pac-container .pac-item-selected"
);
if (selectedItem !== null) {
// Fill-in the address using selected item in the list.
autocomplete.dispatchEvent(event);
//fillInAddress(selectedItem.textContent);
} else {
// Fill-in the address using first item in the list.
fillInAddressUsingFirstItem();
// If the Street Address field changed without clicking (i.e. by pressing Tab, or clicking outside the field)
if (clickedAddress === null) {
// Fill-in the address using first item in the list.
this.fillInAddressUsingFirstItem();
}
});
},
fillInAddress(place) {
if (!place) {
place = this.autocomplete.getPlace();
}
if (place && place.address_components) {
this.matchFound = true;
const self = this;
this.$nextTick(function () {
self.showAddressFields = true;
for (const component of place.address_components) {
const componentType = component.types[0];
switch (componentType) {
case "street_number": {
self.addressModel.streetAddress = component.long_name;
break;
}
case "route": {
self.addressModel.streetAddress += " " + component.short_name;
break;
}
case "locality": {
self.addressModel.city = component.long_name;
break;
}
case "administrative_area_level_1": {
self.addressModel.state = component.short_name;
break;
}
case "postal_code": {
self.addressModel.zipCode = component.long_name;
break;
}
} else {
return;
}
});
addressField1.addEventListener("change", () => {
// If a match has been previously attempted then do nothing
if (self.matchFound !== null) {
return;
}
// Get the address that the user clicked on (if any)
const clickedAddress = document.querySelector(
".pac-container .pac-item:hover"
);
// If the Street Address field changed without clicking (i.e. by pressing Tab, or clicking outside the field)
if (clickedAddress === null) {
// Fill-in the address using first item in the list.
fillInAddressUsingFirstItem();
}
});
function fillInAddressUsingFirstItem() {
// Fill-in the address using first item in the list.
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
const firstResult = item.textContent;
const geocoder = new window.google.maps.Geocoder();
geocoder.geocode(
{
address: firstResult,
},
function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
fillInAddress(results[0]);
self.displayVerificationWarning = true;
}
}
);
} else {
self.matchFound = false;
}
}
function fillInAddress(place) {
if (!place) {
place = autocomplete.getPlace();
}
// After filling in the address fields, disable the address autocomplete
this.unloadAutocomplete();
if (place && place.address_components) {
self.matchFound = true;
self.$nextTick(function () {
self.showAddressFields = true;
for (const component of place.address_components) {
const componentType = component.types[0];
switch (componentType) {
case "street_number": {
self.addressModel.streetAddress = component.long_name;
break;
}
case "route": {
self.addressModel.streetAddress +=
" " + component.short_name;
break;
}
case "locality": {
self.addressModel.city = component.long_name;
break;
}
case "administrative_area_level_1": {
self.addressModel.state = component.short_name;
break;
}
case "postal_code": {
self.addressModel.zipCode = component.long_name;
break;
}
}
}
// after showing the address fields, disable the address autocomplete
window.google.maps.event.removeListener(autocompleteListener);
window.google.maps.event.clearInstanceListeners(autocomplete);
addressField1.onchange = null;
const pacContainer = document.querySelector(".pac-container");
if (pacContainer) {
pacContainer.remove();
}
});
}
}
})
.catch(() => {
// Failed to fetch script
console.log("Unable to load Google Places API script");
// Restore focus to the first address field
this.addressField1.focus();
});
}
},
fillInAddressUsingFirstItem() {
// Fill-in the address using first item in the list.
const item = document.querySelector(".pac-container .pac-item");
if (item != null) {
const firstResult = item.textContent;
const geocoder = new window.google.maps.Geocoder();
const self = this;
geocoder.geocode(
{
address: firstResult,
},
function (results, status) {
if (status === window.google.maps.GeocoderStatus.OK) {
self.fillInAddress(results[0]);
self.displayVerificationWarning = true;
}
}
);
} else {
this.matchFound = false;
}
},
resetAlerts() {
this.displayVerificationWarning = false;
},
unloadAutocomplete() {
if (this.autocompleteListener && this.autocomplete) {
window.google.maps.event.removeListener(this.autocompleteListener);
this.autocompleteListener = null;
window.google.maps.event.clearInstanceListeners(this.autocomplete);
this.autocomplete = null;
this.addressField1.onchange = null;
const pacContainer = document.querySelector(".pac-container");
if (pacContainer) {
pacContainer.remove();
}
}
},
},
mounted() {
this.setupAddressLookup();
// If we already have a full address, show it
this.showAddressFields =
(this.addressModel.streetAddress ?? "") !== "" &&
(this.addressModel.city ?? "") !== "" &&
(this.addressModel.state ?? "") !== "" &&
(this.addressModel.zipCode ?? "") !== "";
if (!this.showAddressFields) {
this.loadGooglePlacesAutocompleteScript();
}
},
unmounted() {
this.unloadAutocomplete();
},
watch: {
matchFound: {
@ -398,7 +420,7 @@ export default {
this.addressModel.state = "";
this.addressModel.zipCode = "";
}
this.showAddressFields = true;
this.displayVerificationWarning = false;
// Only deep watch the Address Model after a failed match
@ -414,11 +436,6 @@ export default {
}
},
},
modelValue: {
handler() {
this.setupAddressLookup();
},
},
},
components: {
textboxQuestion,

View file

@ -1,8 +1,5 @@
import { mount } from "@vue/test-utils";
import contentGroupModal from "./content-group-modal";
import crypto from "crypto";
global.crypto = crypto;
describe("content-group-modal.vue", () => {
it("Should display header text when HeaderText is defined in the CMS", async () => {

View file

@ -21,6 +21,19 @@
aria-hidden="true"
v-on="{ 'show.bs.modal': show, 'hide.bs.modal': hide }"
:style="`height: calc(100% - ${currentFooterAndHeaderHeight}px);`">
<div class="menu-modal-container">
<button
class="menu-button"
type="button"
:class="[isActive ? 'active' : '']"
data-bs-toggle="modal"
data-bs-target="#footerModal"
aria-label="Hamburger Menu (modal window)">
<div class="bar1"></div>
<div class="bar2"></div>
<div class="bar3"></div>
</button>
</div>
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<div class="modal-header visually-hidden">

View file

@ -13,19 +13,19 @@
<div class="col">
<div class="text-container slide">
<p>
Finding shops near you
Tidying up the shop
<span class="dot-1">.</span>
<span class="dot-2">.</span>
<span class="dot-3">.</span>
</p>
<p>
Looking for dates
Getting all the glass shined up
<span class="dot-1">.</span>
<span class="dot-2">.</span>
<span class="dot-3">.</span>
</p>
<p>
Searching for times
Planning your new view of the road
<span class="dot-1">.</span>
<span class="dot-2">.</span>
<span class="dot-3">.</span>
@ -164,19 +164,19 @@ export default {
transform: translateX(-600px);
}
55% {
transform: translateX(-1110px);
transform: translateX(-1195px);
}
66% {
transform: translateX(-1110px);
transform: translateX(-1195px);
}
77% {
transform: translateX(-1600px);
transform: translateX(-1750px);
}
88% {
transform: translateX(-1600px);
transform: translateX(-1750px);
}
100% {
transform: translateX(-2050px);
transform: translateX(-2200px);
}
}

View file

@ -1,22 +1,24 @@
import axios from "axios";
import analyticsMixIn from "@/mixins/analytics-mixin.js";
import store from "@/store";
import router from "@/router";
import { applicationConfig } from "@/constants/application-config.js";
import { GaCategories, GaActions, GaLabels } from "@/constants/analytics";
import { headerKeys } from "@/constants/header-keys";
export default {
callHttpClient({ method, endpoint, payload, logApiCall = true, isFormData = false }) {
callHttpClient({
method,
endpoint,
payload,
logApiCall = true,
isFormData = false,
additionalSuccessEventDataHandler,
}) {
return new Promise((resolve, reject) => {
const cfDistroUrl = applicationConfig.CONSUMER_CF_DISTRO;
let payloadAndAnalyticsData = {};
if (isFormData) {
payloadAndAnalyticsData = payload;
payloadAndAnalyticsData.append("AppName", "FixMyGlass");
} else {
Object.assign(payloadAndAnalyticsData, payload, { AppName: "FixMyGlass" });
}
const payloadAndAnalyticsData = Object.assign({}, payload, { AppName: "FixMyGlass" });
const headers = {
[headerKeys.EXPERIMENT]: JSON.stringify(store.getters.experimentSettings),
};
@ -31,10 +33,16 @@ export default {
}).then(
(response) => {
if (logApiCall) {
let additionalEventData = "";
if (additionalSuccessEventDataHandler) {
additionalEventData = "_" + additionalSuccessEventDataHandler(response);
}
const pageName = analyticsMixIn.methods.getPageName();
const nextPageName = router.lastNavigationPage || pageName;
analyticsMixIn.methods.pushEventToGA(
GaCategories.API_RESPONSE,
GaActions.RESULT,
`${GaLabels.SUCCESS}_${endpoint}`,
`${nextPageName}_${endpoint}`,
`${GaLabels.SUCCESS}${additionalEventData}`,
true
);
}

View file

@ -59,7 +59,7 @@
cmsWidgetName="ServiceZipQuestionWidget"
v-model="serviceZipCode"
ref="serviceZip"
inputId="7add1b26df344f2caf1678de5797803f"
customInputId="serviceZip"
aria-haspopup=""
mask="#####"
validationRules="service-zip-required|service-zip-format" />

View file

@ -6,7 +6,7 @@
cmsWidgetName="FirstNameQuestionWidget"
v-model="customerModel.firstName"
ref="firstName"
inputId="08497a2efd9a4a73a70360ab47b4838d"
customInputId="firstName"
validationRules="first-name-required" />
</div>
</div>
@ -16,7 +16,7 @@
cmsWidgetName="LastNameQuestionWidget"
v-model="customerModel.lastName"
ref="lastName"
inputId="0030e56a57e74a4ab92de7fb8e97fec5"
customInputId="lastName"
validationRules="last-name-required" />
</div>
</div>
@ -26,8 +26,8 @@
cmsWidgetName="EmailAddressQuestionWidget"
v-model="customerModel.emailAddress"
ref="emailAddress"
inputId="00450a91b8964a768ce3992e6feb890f"
validationRules="email-address-required|email-address-format" />
customInputId="emailAddress"
validationRules="email-address-format" />
</div>
</div>
<div class="row mb-0">
@ -38,7 +38,7 @@
</template>
<script>
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import addressQuestions from "@/fmg-components/address-questions/address-questions";
import textboxQuestion from "@/digital-components/textbox-question/textbox-question";
import { defineRule } from "vee-validate";
import { required } from "@/helpers/validation-rules";
@ -49,7 +49,6 @@ import textBlock from "@/digital-components/text-block/text-block";
// DEFINE VALIDATION RULES
defineRule("first-name-required", required(errorMessages.FIRST_NAME_REQUIRED));
defineRule("last-name-required", required(errorMessages.LAST_NAME_REQUIRED));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule(
"email-address-format",
regex(

View file

@ -151,7 +151,6 @@ export default {
if (
store.getters.order.vehicle.carId &&
store.getters.order.serviceLocation.zipCode &&
store.getters.order.customer.emailAddress &&
store.getters.pageData(fmgPageValues.ADDRESS_VEHICLES)
) {
return true;

View file

@ -44,8 +44,8 @@
cmsWidgetName="EmailAddressQuestionWidget"
v-model="emailAddress"
inputId="emailAddress"
isRequired
validationRules="email-address-required|email-address-format" />
disableAutoFill
validationRules="email-address-format" />
</div>
</div>
<div class="row mb-2">
@ -117,7 +117,6 @@ import { queryStrings } from "@/constants/query-strings";
// Define Validation Rules
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule(
"email-address-format",
regex(

View file

@ -13,7 +13,7 @@
cmsWidgetName="LicensePlateNumberQuestionWidget"
v-model="licensePlate"
isRequired
inputId="license_plate"
customInputId="licensePlate"
validationRules="license-plate-required" />
</div>
</div>
@ -22,9 +22,9 @@
<textboxQuestion
cmsWidgetName="RegistrationZipQuestionWidget"
v-model="registrationZipCode"
inputId="zip"
customInputId="zip"
mask="#####"
validationRules="zip-required|zip-format" />
validationRules="registration-zip-required|zip-format" />
</div>
</div>
<div class="row mt-0">
@ -32,8 +32,8 @@
<textboxQuestion
cmsWidgetName="EmailAddressQuestionWidget"
v-model="email"
inputId="email"
validationRules="email-address-required|email-address-format" />
customInputId="email"
validationRules="email-address-format" />
</div>
</div>
<div class="row mb-2">
@ -113,9 +113,8 @@ import vinPagesMixin from "@/mixins/vin-pages-mixin";
// DEFINE VALIDATION RULES
defineRule("license-plate-required", required(errorMessages.LICENSE_PLATE_REQUIRED));
defineRule("zip-required", required(errorMessages.REGISTRATION_ZIP_REQUIRED));
defineRule("registration-zip-required", required(errorMessages.REGISTRATION_ZIP_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule(
"email-address-format",
regex(
@ -300,11 +299,12 @@ export default {
);
await this.dispatchStoreAction(storeActions.SAVE_EMAIL, this.email, false);
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_LOCATION,
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
zipCode: this.serviceZipCode,
state: resultMap.serviceZipValidationResponse.state,
zipCode: this.serviceZipCode,
zipCodeCtu: resultMap.serviceZipValidationResponse.zipCodeCtu,
},
false

View file

@ -38,8 +38,7 @@
args: getRouterLinkRouteFromCopy(copy),
})
"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
aria-label="Modal window" />
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)" />
</span>
</template>
</li>

View file

@ -11,3 +11,10 @@ export async function getAlertReasons(ctu) {
return Promise.resolve(alertReasons);
}
export function calcDaysBetweenDates(dateString1, dateString2) {
const date1 = new Date(dateString1);
const date2 = new Date(dateString2);
const timeDifference = Math.abs(date2 - date1); // Calculate the time difference in milliseconds
return Math.ceil(timeDifference / (1000 * 60 * 60 * 24)); // Convert milliseconds to days
}

View file

@ -25,6 +25,7 @@
mobileCmsWidgetName="MobileTimeSlotModal"
dropoffCmsWidgetName="DropOffTimeSlotModal"
sameDayDropOffCmsWidgetName="SameDayDropOffTimeSlotModal"
overnightDropOffCmsWidgetName="OvernightDropOffTimeSlotModal"
v-model="selectedTimeSlotData"
@time-slot-modal-closed="timeSlotModalClosed"
:appointmentType="appointmentType"
@ -62,6 +63,7 @@ import { storeActions } from "@/constants/store-actions";
import { settleAllPromises } from "@/helpers/layout-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { splitCopyOnCMSPlaceHolder } from "@/helpers/cms-content-helper";
import { calcDaysBetweenDates } from "@/layouts/schedule/helpers/schedule-helper";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
@ -70,33 +72,98 @@ import store from "@/store";
defineRule("date-required", required(errorMessages.DATE_REQUIRED));
defineRule("time-slot-selection-required", required(errorMessages.DATE_REQUIRED));
const getAvailableDates = async (startDate, endDate, appointmentType, providerNumber) => {
// USING DATES PASSED, MAKE AN API CALL
// Define constants
const TIME_SLOTS_CALL_DAYS_LIMIT = 34; // needs to be 34 for API limits (35 does not consistently work)
let newTimeSlotsResponse;
if (appointmentType === AppointmentTypeStrings.MOBILE) {
newTimeSlotsResponse = await baseMixin.methods.dispatchStoreAction(
storeActions.GET_MOBILE_TIME_SLOTS,
{
startDate: startDate,
endDate: endDate,
},
false
);
} else {
newTimeSlotsResponse = await baseMixin.methods.dispatchStoreAction(
storeActions.GET_SHOP_TIME_SLOTS,
{
startDate: startDate,
endDate: endDate,
shopAppointmentType: appointmentType,
providerNumber: providerNumber,
},
false
);
const getAvailableDates = async (
startDateString,
endDateString,
appointmentType,
providerNumber
) => {
const apiEndDateLimit = new Date(startDateString + "T00:00:00");
const endDate = new Date(endDateString + "T00:00:00");
apiEndDateLimit.setDate(apiEndDateLimit.getDate() + TIME_SLOTS_CALL_DAYS_LIMIT);
// how many days are between startDate and endDate?
const difference = calcDaysBetweenDates(startDateString, endDateString);
const apiCallsCount = Math.ceil(difference / TIME_SLOTS_CALL_DAYS_LIMIT);
const storeActionConfigs = [];
const timeSlotsData = {};
let apiStartDate = new Date(startDateString + "T00:00:00");
let apiEndDate = apiEndDateLimit;
timeSlotsData.days = [];
for (let i = 1; i <= apiCallsCount; i++) {
let storeActionConfig;
if (i > 1) {
apiStartDate = new Date(apiEndDate);
apiStartDate.setDate(apiStartDate.getDate() + 1);
apiEndDate = new Date(apiStartDate);
apiEndDate.setDate(apiEndDate.getDate() + TIME_SLOTS_CALL_DAYS_LIMIT);
}
if (i === apiCallsCount) {
apiEndDate = new Date(endDateString + "T00:00:00");
}
if (appointmentType === AppointmentTypeStrings.MOBILE) {
storeActionConfig = {
storeAction: storeActions.GET_MOBILE_TIME_SLOTS,
payload: {
startDate: apiStartDate.toISOString().split("T")[0],
endDate: apiEndDate.toISOString().split("T")[0],
},
};
} else {
storeActionConfig = {
storeAction: storeActions.GET_SHOP_TIME_SLOTS,
payload: {
startDate: apiStartDate.toISOString().split("T")[0],
endDate: apiEndDate.toISOString().split("T")[0],
shopAppointmentType: appointmentType,
providerNumber: providerNumber,
},
};
}
storeActionConfigs.push(storeActionConfig);
}
return newTimeSlotsResponse.data;
// ASYNC METHOD
const timeSlotsResponsesData = {
days: [],
};
function compareDayStrings(a, b) {
if (a.date < b.date) return -1;
if (a.date > b.date) return 1;
return 0;
}
const makeParallelCalls = async () => {
await Promise.all(
storeActionConfigs.map(async (storeAction) => {
const timeSlotsResponse = await baseMixin.methods.dispatchStoreAction(
storeAction.storeAction,
storeAction.payload,
false
);
timeSlotsResponsesData.estimatedServiceMinutesMinimum =
timeSlotsResponse.data.estimatedServiceMinutesMinimum;
timeSlotsResponsesData.estimatedServiceMinutesMaximum =
timeSlotsResponse.data.estimatedServiceMinutesMaximum;
timeSlotsResponsesData.days = [
...timeSlotsResponsesData.days,
...timeSlotsResponse.data.days,
];
})
);
};
return makeParallelCalls().then(() => {
// sort days chronologically
timeSlotsResponsesData.days.sort(compareDayStrings);
return timeSlotsResponsesData;
});
};
export default {
@ -106,7 +173,7 @@ export default {
selectedDate: this.getSelectedDate(),
selectedTimeSlotData: {
id: this.getSelectedRouteCode(),
isPremiumAppointment: null,
isPremiumAppointment: this.isMobilePremiumFeeOnOrderInVuex(),
},
selectableDatesData: [],
mobilePremiumAppointmentFee: null,
@ -115,11 +182,17 @@ export default {
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.fmgPage);
const datePickerInitialDataPromise = datePicker.methods.loadInitialData({
let preSelectedDate = await store.getters.order.schedule.date;
if (!preSelectedDate || preSelectedDate.startTime === null) {
preSelectedDate = null;
}
const datePickerInitialDataPromise = await datePicker.methods.loadInitialData({
// setup config options for date-picker
selectableDatesSetting: "custom",
initialViewRowsToShow: 5,
customSelectableDatesCallback: getAvailableDates,
preSelectedDate: preSelectedDate,
});
const premiumFeePromise = baseMixin.methods.dispatchStoreAction(
@ -142,7 +215,7 @@ export default {
const alertReasonsPromise = locationAlerts.methods.loadInitialData(
store.getters.order.serviceLocation.zipCodeCtu,
store.getters.order.serviceLocation.provider?.address?.zipCtu
store.getters.order.serviceLocation.provider?.address?.zipCodeCtu
);
// Settle promises and get results
const promiseResultMap = [
@ -258,8 +331,8 @@ export default {
getTimeSlotObjectFromTimeSlotId(timeSlotId) {
const timeSlots = this.selectableDatesData.days.find(
(selectableDate) => selectableDate.date === this.selectedDate
).timeSlots;
return timeSlots.find((timeSlot) => timeSlot.id === timeSlotId);
)?.timeSlots;
if (timeSlots) return timeSlots.find((timeSlot) => timeSlot.id === timeSlotId);
},
getSelectedDate() {
return store.getters.order.schedule.date;
@ -267,6 +340,12 @@ export default {
getSelectedRouteCode() {
return store.getters.order.schedule.routeCode;
},
isMobilePremiumFeeOnOrderInVuex() {
const supportingItemsFromVuex = store.getters.lineItems.supportingItems;
return !!supportingItemsFromVuex.filter(
(lineItem) => lineItem.partType === PREMIUM_FEE_PART_TYPE
).length;
},
timeSlotModalClosed() {
// Clear the selectedDate if no timeSlot has been selected
if (!this.selectedTimeSlotData.id) {
@ -275,7 +354,7 @@ export default {
},
updateFooterButtonText(timeSlotData) {
let funnelFooterButtonText;
if (!timeSlotData.id) {
if (!timeSlotData.id || !this.appointmentDateAndTime) {
funnelFooterButtonText = "Continue";
} else {
funnelFooterButtonText =

View file

@ -28,8 +28,8 @@
v-if="supplementalInformationBlock"
v-html="supplementalInformationBlock"></div>
<textBlock
v-show="shouldShowDropoffDisclaimerText"
:customText="dropoffDisclaimerText"
v-show="disclaimerTextBlockCopy"
:customText="disclaimerTextBlockCopy"
justifyText="left"
typeStyle="caption"
class="mb-2" />
@ -53,6 +53,7 @@ import {
AppointmentTypeStrings,
PREMIUM_TIME_SLOT_ID_FLAG,
PREMIUM_FEE_PART_TYPE,
RouteCodeFlags,
} from "@/constants/schedule-constants";
// Validation for the modal button
@ -69,6 +70,7 @@ export default {
mobilePremiumCmsWidgetName: String,
dropoffCmsWidgetName: String,
sameDayDropOffCmsWidgetName: String,
overnightDropOffCmsWidgetName: String,
appointmentType: String,
dateAndTimeSlotData: Object,
premiumAppointmentFee: Object,
@ -78,7 +80,7 @@ export default {
},
data() {
return {
selectedTimeSlotId: this.modelValue.id,
selectedTimeSlotId: this.getModifiedSelectedTimeSlotId(),
timeSlotModalListButton: timeSlotModalListButton,
};
},
@ -92,12 +94,8 @@ export default {
},
watch: {
modelValue() {
this.selectedTimeSlotId = this.getModifiedSelectedTimeSlotId();
// Run component validation that is used at parent level
if (this.modelValue.isPremiumAppointment) {
this.selectedTimeSlotId = this.addPremiumFlagToInput(this.modelValue.id);
} else {
this.selectedTimeSlotId = this.modelValue.id;
}
this.handleChange(this.modelValue.id);
},
availableTimeSlots(newValue) {
@ -116,9 +114,15 @@ export default {
? this.mobilePremiumCmsWidgetName
: this.mobileCmsWidgetName;
} else {
appointmentTypeCmsWidgetName = this.isSameDay
? this.sameDayDropOffCmsWidgetName
: this.dropoffCmsWidgetName;
if (!this.selectedTimeSlotId) {
return null;
} else {
appointmentTypeCmsWidgetName =
this.getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
this.selectedTimeSlotId,
true
);
}
}
return this.getCmsContent(appointmentTypeCmsWidgetName, "BodyText");
},
@ -131,12 +135,36 @@ export default {
dropoffButtonText() {
return this.getCmsContent(this.dropoffCmsWidgetName, "HeaderText");
},
overnightDropoffButtonText() {
return this.getCmsContent(this.overnightDropOffCmsWidgetName, "HeaderText");
},
dropoffDisclaimerText() {
return this.getCmsContent(this.dropoffCmsWidgetName, "FooterText");
},
overnightDropOffDisclaimerText() {
return this.getCmsContent(this.overnightDropOffCmsWidgetName, "FooterText");
},
disclaimerTextBlockCopy() {
if (this.appointmentType === AppointmentTypeStrings.DROP_OFF) {
if (this.isSameDay) {
return null;
} else if (this.selectedTimeSlotId?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
return this.dropoffDisclaimerText;
} else if (this.selectedTimeSlotId?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropOffDisclaimerText;
} else {
return null;
}
} else {
return null;
}
},
dropOffDurationText() {
return this.getCmsContent(this.dropoffCmsWidgetName, "SubheaderText");
},
overnightDropoffDurationText() {
return this.getCmsContent(this.overnightDropOffCmsWidgetName, "SubheaderText");
},
inshopDurationText() {
const inshopDurationTextWithoutTime = this.getCmsContent(
this.cmsWidgetName,
@ -154,15 +182,27 @@ export default {
} else if (this.appointmentType === AppointmentTypeStrings.IN_SHOP) {
return this.inshopDurationText;
} else {
return this.dropOffDurationText;
if (this.selectedTimeSlotId?.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropoffDurationText;
} else if (this.selectedTimeSlotId?.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
return this.dropOffDurationText;
} else {
return null;
}
}
},
shouldShowDropoffDisclaimerText() {
return this.appointmentType === AppointmentTypeStrings.DROP_OFF && !this.isSameDay;
},
isSameDay() {
return false;
if (!this.dateAndTimeSlotData) {
return false;
}
const selectedDate = this.dateAndTimeSlotData.date;
const todaysDate = new Date().toISOString().split("T")[0];
return selectedDate === todaysDate;
},
dateSelectedReadableDate() {
if (!this.dateAndTimeSlotData) {
return null;
@ -212,6 +252,13 @@ export default {
onModalClosed() {
this.$emit("time-slot-modal-closed");
},
getModifiedSelectedTimeSlotId() {
if (this.modelValue.isPremiumAppointment) {
return this.addPremiumFlagToInput(this.modelValue.id);
} else {
return this.modelValue.id;
}
},
// Expected input: "HH:MM"
getDisplayTextForMilitaryTime(militaryTimeInput) {
let hours = parseInt(militaryTimeInput.split(":")[0]);
@ -233,6 +280,18 @@ export default {
}
return displayTextForDurationLength;
},
getRelevantDropOffCmsWidgetNameForSelectedTimeSlot(
selectedTimeSlotId,
isSameDayRelevant = false
) {
if (selectedTimeSlotId.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
return this.overnightDropOffCmsWidgetName;
} else if (selectedTimeSlotId.includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
return this.isSameDay && isSameDayRelevant
? this.sameDayDropOffCmsWidgetName
: this.dropoffCmsWidgetName;
}
},
getAvailableTimeSlotsForInshop(timeSlotsForSelectedDate) {
return timeSlotsForSelectedDate.map((timeSlot) => {
const readableTime = this.getDisplayTextForMilitaryTime(timeSlot.startTime);
@ -243,12 +302,16 @@ export default {
});
},
getAvailableTimeSlotsForDropOff(timeSlotsForSelectedDate) {
return [
{
value: timeSlotsForSelectedDate[0].id,
buttonLabel: this.dropoffButtonText,
},
];
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {
const buttonLabelValue = timeSlot.id.includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)
? this.overnightDropoffButtonText
: this.dropoffButtonText;
return {
value: timeSlot.id,
buttonLabel: buttonLabelValue,
};
});
return availableTimeSlots;
},
getAvailableTimeSlotsForMobile(timeSlotsForSelectedDate) {
const availableTimeSlots = timeSlotsForSelectedDate.map((timeSlot) => {

View file

@ -95,8 +95,10 @@ export default {
</script>
<style lang="scss">
.list-card img {
height: auto;
width: 3.417rem;
.appointment-type-question {
.list-card img {
height: auto;
width: 3.417rem;
}
}
</style>

View file

@ -42,7 +42,8 @@ jest.mock("@/store", () => ({
streetAddress: null,
city: null,
state: null,
zip: null,
zipCode: null,
zipCodeCtu: null,
},
},
},

View file

@ -2,11 +2,6 @@ import mobileLocationModalQuestions from "./mobile-location-modal-questions";
import { mount, shallowMount } from "@vue/test-utils";
import { storeActions } from "@/constants/store-actions";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import modal from "@/digital-components/modal/modal";
import crypto from "crypto";
global.crypto = crypto;
const linkWidgetName = "linkWidgetName";
const modalWidgetName = "modalWidgetName";

View file

@ -14,8 +14,7 @@
linkType="text"
:text="mobileLocationLinkText"
href="#!"
@click-event="openModal"
aria-label="Modal window" />
@click-event="openModal" />
</div>
<div v-show="errorMessage" class="row my-1 form-test-error">
<span class="d-inline-flex small mt-0 center-error-message" role="alert">
@ -33,24 +32,27 @@
:footerButtonText="modalFooterText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@isModalOpened="setModalStatus"
@footer-button-event="setMobileLocation">
<addressQuestions
ref="addressQuestions"
v-model="internalModel.addressQuestions"
captureApartmentNumberOrBusinessName="true"
preserveCityAndStateOnReset="true" />
<vehicleProtectedQuestion
ref="vehicleProtectedQuestion"
v-model="internalModel.isVehicleProtected"
cmsWidgetName="VehicleProtectedQuestionWidget" />
<textBlock cmsWidgetName="WorkspaceRequirementsWidget" typeStyle="caption" />
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"
class="my-4"
cmsWidgetName="AlertInvalidZipWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
<template v-if="isModalOpened">
<addressQuestions
ref="addressQuestions"
v-model="internalModel.addressQuestions"
captureApartmentNumberOrBusinessName="true"
preserveCityAndStateOnReset="true" />
<vehicleProtectedQuestion
ref="vehicleProtectedQuestion"
v-model="internalModel.isVehicleProtected"
cmsWidgetName="VehicleProtectedQuestionWidget" />
<textBlock cmsWidgetName="WorkspaceRequirementsWidget" typeStyle="caption" />
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"
class="my-4"
cmsWidgetName="AlertInvalidZipWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</template>
</modal>
</div>
</transition>
@ -62,7 +64,7 @@ import textLink from "@/ux-components/text-link/text-link";
import textBlock from "@/digital-components/text-block/text-block";
import modal from "@/digital-components/modal/modal";
import alert from "@/ux-components/alert/alert";
import addressQuestions from "@/layouts/address-lookup/customer-questions/address-questions/address-questions";
import addressQuestions from "@/fmg-components/address-questions/address-questions";
import vehicleProtectedQuestion from "@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question";
// Helpers
@ -74,6 +76,7 @@ import {
// Validation
import { useField } from "vee-validate";
import { v4 as uuidv4 } from "uuid";
export default {
name: "mobile-location-modal-questions",
@ -82,11 +85,13 @@ export default {
return {
internalModel: deepClone(this.modelValue),
displayInvalidZipAlert: false,
isModalOpened: false,
};
},
setup(props) {
const uuid = uuidv4();
const componentId = !props.customComponentId
? `component-${crypto.randomUUID()}`
? `component-${uuid}`
: props.customComponentId;
// Integrate this component as a single field with an object for it's value into the page level validation
@ -185,6 +190,9 @@ export default {
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
},
modal() {
return this.$refs[this.modalName];
},
addressModel: {
get: function () {
return this.modelValue.addressQuestions;
@ -193,48 +201,23 @@ export default {
},
methods: {
openModal() {
this.$refs[this.modalName].openModal();
},
closeModal() {
this.$refs[this.modalName].closeModal();
this.modal.openModal();
},
onModalOpened() {
this.internalModel = deepClone(this.modelValue);
},
setModalStatus(isOpened) {
this.isModalOpened = isOpened;
},
closeModal() {
this.modal.closeModal();
},
onModalClosed() {
this.displayInvalidZipAlert = false;
this.internalModel = deepClone(this.modelValue);
this.resetValidation();
},
resetComponent(updatedServiceZipCodeInfo) {
// Reset the validation form, setting the initial values
// for the state and zipCode to those that were entered
// on the service-zip-modal-question component
this.$refs[this.modalName].resetForm({
values: {
autocomplete: updatedServiceZipCodeInfo.streetAddress,
city: updatedServiceZipCodeInfo.city,
state: updatedServiceZipCodeInfo.state,
zipCode: updatedServiceZipCodeInfo.zipCode,
isVehicleProtected: updatedServiceZipCodeInfo.isVehicleProtected,
},
});
},
resetModalButtonStyle() {
this.$refs[this.modalName].resetButtonStyle();
},
resetValidation() {
this.$refs.addressQuestions.resetAlerts();
this.$refs[this.modalName].resetForm({
values: {
autocomplete: this.internalModel.addressQuestions.streetAddress,
city: this.internalModel.addressQuestions.city,
state: this.internalModel.addressQuestions.state,
zipCode: this.internalModel.addressQuestions.zipCode,
isVehicleProtected: this.internalModel.isVehicleProtected,
},
});
this.modal.resetButtonStyle();
},
async setMobileLocation() {
if (
@ -284,14 +267,6 @@ export default {
this.internalModel = deepClone(newValue);
this.handleChange(newValue);
this.resetComponent({
streetAddress: newValue.addressQuestions.streetAddress,
city: newValue.addressQuestions.city,
state: newValue.addressQuestions.state,
zipCode: newValue.addressQuestions.zipCode,
isVehicleProtected: newValue.isVehicleProtected,
});
},
deep: true,
},

View file

@ -68,15 +68,13 @@
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget"
:onZipUpdateCallback="reloadShopData" />
<Transition name="fade" mode="out-in">
<shopQuestion
ref="shopQuestion"
v-show="isShopQuestionDisplayed"
v-model="selectedProvider"
:selectedAppointmentType="selectedAppointmentType"
:isDisplayed="isShopQuestionDisplayed"
cmsWidgetName="ShopQuestionWidget" />
</Transition>
<shopQuestion
ref="shopQuestion"
v-show="isShopQuestionDisplayed"
v-model="selectedProvider"
:selectedAppointmentType="selectedAppointmentType"
:isDisplayed="isShopQuestionDisplayed"
cmsWidgetName="ShopQuestionWidget" />
<contentGroupModal ref="RecalModal" cmsWidgetName="RecalModal" />
<funnel-footer
cmsWidgetName="FunnelFooterWidget"
@ -108,7 +106,6 @@ import baseMixin from "@/mixins/base-mixin.js";
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { settleAllPromises } from "@/helpers/layout-helper";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import {
getPricedMobileFeePart,
getServiceabilityDetails,
@ -396,8 +393,8 @@ export default {
streetAddress: this.selectedProvider?.address?.streetAddress,
city: this.selectedProvider?.address?.city,
state: this.selectedProvider?.address?.state,
zip: this.selectedProvider?.address?.zipCode,
zipCtu: this.selectedProvider?.address?.zipCodeCtu,
zipCode: this.selectedProvider?.address?.zipCode,
zipCodeCtu: this.selectedProvider?.address?.zipCodeCtu,
},
},
},

View file

@ -1,7 +1,5 @@
import { mount, shallowMount } from "@vue/test-utils";
import serviceZipModalQuestion from "./service-zip-modal-question";
import crypto from "crypto";
global.crypto = crypto;
jest.mock("@/digital-components/textbox-question/textbox-question", () => ({
getCmsContent: jest.fn((widgetName, cmsFieldName) => {

View file

@ -6,8 +6,7 @@
linkType="text"
:text="serviceZipLinkText"
href="#!"
@click-event="openModal"
aria-label="Modal window" />
@click-event="openModal" />
</div>
</div>
<modal
@ -22,12 +21,12 @@
customInputId="serviceZipCode"
v-model="internalModel.zipCode"
v-on="{ 'textboxQuestionEvent.inputIdAssigned': onInputIdAssigned }"
:cmsWidgetName="textboxQuestionWidgetName" />
cmsWidgetName="ServiceZipQuestionWidget" />
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"
class="my-4"
:cmsWidgetName="alertInvalidZipWidgetName"
cmsWidgetName="AlertInvalidZipWidget"
alertClass="alert-danger"
v-bind:isDismissible="false" />
</modal>
@ -73,15 +72,6 @@ export default {
type: Function,
},
},
setup() {
const textboxQuestionWidgetName = "ServiceZipQuestionWidget";
const alertInvalidZipWidgetName = "AlertInvalidZipWidget";
return {
textboxQuestionWidgetName,
alertInvalidZipWidgetName,
};
},
computed: {
serviceZipLinkText() {
if (this.modelValue.zipCode && this.modelValue.zipCode.length > 0) {
@ -90,7 +80,7 @@ export default {
return this.getCmsContent(this.linkWidgetName, "BodyText");
},
modalHeaderText() {
return this.getCmsContent(this.textboxQuestionWidgetName, "QuestionText");
return this.getCmsContent("ServiceZipQuestionWidget", "QuestionText");
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, "FooterText");
@ -98,6 +88,9 @@ export default {
modalName() {
return this.modalWidgetName;
},
modal() {
return this.$refs[this.modalName];
},
},
methods: {
resetAlerts() {
@ -117,13 +110,13 @@ export default {
};
},
openModal() {
this.$refs[this.modalName].openModal();
this.modal.openModal();
},
closeModal() {
this.$refs[this.modalName].closeModal();
this.modal.closeModal();
},
resetModalButtonStyle() {
this.$refs[this.modalName].resetButtonStyle();
this.modal.resetButtonStyle();
},
onInputIdAssigned(inputId) {
this.serviceZipCodeTextInputId = inputId;

View file

@ -13,7 +13,7 @@
<textboxQuestion
cmsWidgetName="VinNumberQuestionWidget"
v-model="vin"
inputId="vin"
customInputId="vin"
isRequired
validationRules="vin-required|vin-format"
:isDisabled="vinPopulatedOnPageLoad"
@ -46,7 +46,7 @@
<textboxQuestion
cmsWidgetName="ServiceZipQuestionWidget"
v-model="serviceZipCode"
inputId="serviceZipCode"
customInputId="serviceZipCode"
mask="#####"
isRequired
validationRules="zip-required|zip-format" />
@ -57,9 +57,9 @@
<textboxQuestion
cmsWidgetName="EmailAddressQuestionWidget"
v-model="emailAddress"
inputId="emailAddress"
customInputId="emailAddress"
isRequired
validationRules="email-address-required|email-address-format" />
validationRules="email-address-format" />
</div>
</div>
<div class="row mb-0">
@ -152,7 +152,6 @@ import vinPagesMixin from "@/mixins/vin-pages-mixin";
// DEFINE VALIDATION RULES
defineRule("zip-required", required(errorMessages.SERVICE_ZIP_REQUIRED));
defineRule("zip-format", regex(/(^\d{5}$)|(^\d{5}-\d{4}$)/, errorMessages.SERVICE_ZIP_FORMAT));
defineRule("email-address-required", required(errorMessages.EMAIL_ADDRESS_REQUIRED));
defineRule(
"email-address-format",
regex(
@ -325,8 +324,8 @@ export default {
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
zipCode: this.serviceZipCode,
state: resultMap.zipCodeData.state,
zipCode: this.serviceZipCode,
zipCodeCtu: resultMap.zipCodeData.zipCodeCtu,
},
false
@ -352,8 +351,8 @@ export default {
{
address: vehicleRegistrationInfo.address,
city: vehicleRegistrationInfo.city,
zipCode: vehicleRegistrationInfo.zipCode,
state: vehicleRegistrationInfo.state,
zipCode: vehicleRegistrationInfo.zipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
},
false
@ -362,8 +361,8 @@ export default {
await this.dispatchStoreAction(
storeActions.SAVE_SERVICE_ZIP_CODE_INFO,
{
zipCode: this.serviceZipCode,
state: zipCodeData.state,
zipCode: this.serviceZipCode,
zipCodeCtu: zipCodeData.zipCodeCtu,
},
false

View file

@ -23,6 +23,10 @@ import { applicationConfig } from "../constants/application-config";
export default {
methods: {
getPageName() {
return getPageNameByQueryString();
},
logPageView(pageEvent) {
const currentPageName = getPageNameByQueryString();
var payload = {

View file

@ -162,6 +162,14 @@ const router = createRouter({
//---------------------------------------------------------- Router Functions ----------------------------------------------------------
router.beforeEach(async (to, from, next) => {
// set lastNavigationPage here to capture state before API calls for analytics.
// use current page url query string name when to.name is "root" (due to unresolved navigation in beforeEach)
router.lastNavigationPage = to.name == "root" ? analyticsMixin.methods.getPageName() : to.name;
next();
});
router.afterEach(async (to, from) => {
// Update lastPageVisited in the store
store.commit(storeMutations.UPDATE_LAST_PAGE_VISITED, to.name);
@ -228,6 +236,12 @@ router.overrideNavigation = (
next();
};
router.getNextPage = () => nextPageName;
// PRIVATE VARIABLES
var nextPageName;
// PRIVATE FUNCTIONS
// Navigate to the next route, depending on the scenario.
@ -251,6 +265,8 @@ async function navigate(
if (destinationFmgPageValue !== undefined) {
// We're always pushing the same path, just changing query strings. Make sure our optional query strings get combined with our fmgPage one.
nextPageName = destinationFmgPageValue;
// Update page data to the store for next page if provided. Otherwise, keep existing page data or set to empty object
const existingPageDataForPage = store.getters.pageData(destinationFmgPageValue);
baseMixin.methods.savePageDataToStore(

View file

@ -8,6 +8,7 @@ import { storeActions } from "@/constants/store-actions";
import { applicationConfig } from "@/constants/application-config";
import { experimentTriggers } from "@/constants/experiments";
import { damageLocationsSelected } from "@/constants/damage-locations-selected";
import { singleWindshieldCarIds } from "@/constants/single-windshield-carids";
import { fmgPageValues } from "@/router/router-constants/fmgPage-values";
import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper.js";
import { deepEqual } from "@/helpers/object-helper";
@ -53,7 +54,8 @@ const getDefaultState = () => {
streetAddress: null,
city: null,
state: null,
zip: null,
zipCode: null,
zipCodeCtu: null,
},
},
},
@ -243,6 +245,11 @@ export const mutations = {
state.order.vehicle.registration.firstName = registrationInfo?.firstName;
state.order.vehicle.registration.lastName = registrationInfo?.lastName;
},
updateServiceZip(state, serviceZipInfo) {
state.order.serviceLocation.state = serviceZipInfo.state;
state.order.serviceLocation.zipCode = serviceZipInfo.zipCode;
state.order.serviceLocation.zipCodeCtu = serviceZipInfo.zipCodeCtu;
},
updateServiceLocation(state, serviceLocationInfo) {
state.order.serviceLocation.address = serviceLocationInfo.address;
state.order.serviceLocation.address2 = serviceLocationInfo.address2;
@ -253,9 +260,16 @@ export const mutations = {
state.order.serviceLocation.appointmentType = serviceLocationInfo.appointmentType;
state.order.serviceLocation.isVehicleProtected = serviceLocationInfo.isVehicleProtected;
if (serviceLocationInfo.provider) {
state.order.serviceLocation.provider = serviceLocationInfo.provider;
}
state.order.serviceLocation.provider = {
providerNumber: serviceLocationInfo.provider?.providerNumber,
address: {
streetAddress: serviceLocationInfo.provider?.address?.streetAddress,
city: serviceLocationInfo.provider?.address?.city,
state: serviceLocationInfo.provider?.address?.state,
zipCode: serviceLocationInfo.provider?.address?.zipCode,
zipCodeCtu: serviceLocationInfo.provider?.address?.zipCodeCtu,
},
};
},
updateSchedule(state, scheduleInfo) {
if (scheduleInfo) {
@ -351,14 +365,14 @@ export const mutations = {
state.order.schedule.routeCode = null;
state.order.schedule.jobMaxMinutes = null;
//early bird fee used on schedule page also needs reset when schedule is reset
//premium appointment fee used on schedule page also needs reset when schedule is reset
const supportingItems = state.order.lineItems.supportingItems;
const removeEarlyBirdIndex = supportingItems?.findIndex(
const premiumAppointmentFeeIndex = supportingItems?.findIndex(
(item) => item.partType == PREMIUM_FEE_PART_TYPE
);
if (removeEarlyBirdIndex >= 0) {
supportingItems.splice(removeEarlyBirdIndex, 1);
if (premiumAppointmentFeeIndex >= 0) {
supportingItems.splice(premiumAppointmentFeeIndex, 1);
state.order.lineItems.supportingItems = supportingItems;
}
},
@ -372,14 +386,22 @@ export const mutations = {
state.order.serviceLocation.appointmentType = null;
},
resetServiceLocationProvider(state) {
state.order.serviceLocation.provider = null;
state.order.serviceLocation.provider = {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null,
},
};
},
resetServiceLocationMobileAddress(state) {
state.order.serviceLocation.address = null;
state.order.serviceLocation.address2 = null;
state.order.serviceLocation.city = null;
state.order.serviceLocation.state = null;
state.order.serviceLocation.zipCode = null;
state.order.serviceLocation.isVehicleProtected = null;
},
// Misc Mutations
@ -460,8 +482,10 @@ export const mutations = {
sessionInformation.order.serviceLocation.provider?.address?.city;
state.order.serviceLocation.provider.address.state =
sessionInformation.order.serviceLocation.provider?.address?.state;
state.order.serviceLocation.provider.address.zip =
sessionInformation.order.serviceLocation.provider?.address?.zip;
state.order.serviceLocation.provider.address.zipCode =
sessionInformation.order.serviceLocation.provider?.address?.zipCode;
state.order.serviceLocation.provider.address.zipCodeCtu =
sessionInformation.order.serviceLocation.provider?.address?.zipCodeCtu;
state.order.payment.isInsurance = sessionInformation.order.payment.isInsurance;
state.order.payment.insuranceCoverage.isVerified =
@ -639,13 +663,28 @@ export const actions = {
},
lookupVinByImage(context, image) {
const data = new FormData();
data.append("vinImage", image);
return globalMethods.callHttpClient({
method: endpoints.LookupVinByImage.method,
endpoint: endpoints.LookupVinByImage.url,
payload: data,
isFormData: true,
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = (e) => {
resolve(reader.result);
};
reader.readAsDataURL(image);
}).then((result) => {
const components = result.split(",");
const contentType = image.type;
const imageBase64 = components[1];
const data = {
imageData: imageBase64,
contentType: contentType,
fileName: image.name,
};
return globalMethods.callHttpClient({
method: endpoints.LookupVinByImage.method,
endpoint: endpoints.LookupVinByImage.url,
payload: data,
});
});
},
@ -722,20 +761,30 @@ export const actions = {
// Dependency Actions
resetDamageAndDependencies(context) {
context.commit(storeMutations.RESET_DAMAGE_STATE);
context.commit(storeMutations.RESET_GLASS_PARTS_STATE);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
context.commit(storeMutations.UPDATE_VAPS, null);
},
resetRegistrationAndDependencies(context) {
context.commit(storeMutations.RESET_REGISTRATION_STATE);
context.commit(storeMutations.RESET_GLASS_PARTS_STATE);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
},
resetPartsAndDependencies(context) {
context.commit(storeMutations.RESET_GLASS_PARTS_STATE);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
},
resetServiceLocationAndDependencies(context) {
context.commit(storeMutations.RESET_SERVICE_LOCATION_APPOINTMENT_TYPE);
context.commit(storeMutations.RESET_SERVICE_LOCATION_PROVIDER);
context.commit(storeMutations.RESET_SCHEDULE);
},
resetState(context) {
@ -1333,7 +1382,7 @@ export const actions = {
},
},
customer: {
emailAddress: order.customer.emailAddress,
emailAddress: order.customer.emailAddress || null,
},
damage: {
numberOfChips: damage.numberOfChips,
@ -1372,8 +1421,8 @@ export const actions = {
order.serviceLocation.provider?.address?.streetAddress,
city: order.serviceLocation.provider?.address?.city,
state: order.serviceLocation.provider?.address?.state,
zip: order.serviceLocation.provider?.address?.zip,
zipCtu: order.serviceLocation.provider?.address?.zipCtu,
zipCode: order.serviceLocation.provider?.address?.zipCode,
zipCodeCtu: order.serviceLocation.provider?.address?.zipCodeCtu,
},
},
},
@ -1392,6 +1441,8 @@ export const actions = {
eon: order.eon,
},
},
additionalSuccessEventDataHandler: (response) =>
"Email provided: " + (order.customer.emailAddress ? "true" : "false"),
});
},
@ -1607,7 +1658,6 @@ export const actions = {
if (!isSelectedGlassAvailableForVehicle) {
context.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
}
//Save new values
@ -1633,7 +1683,6 @@ export const actions = {
if (!isSelectedGlassAvailableForVehicle) {
context.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
}
//Save new values
@ -1659,6 +1708,8 @@ export const actions = {
);
if (havePartQuestionAnswersChanged) {
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, null);
@ -1706,6 +1757,8 @@ export const actions = {
previouslySelectedPartNumbers !== currentlySelectedPartNumbers;
if (haveSelectedVehiclePartsChanged) {
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
context.commit(storeMutations.UPDATE_MOLDING_QUESTION_ANSWERS, null);
@ -1737,6 +1790,8 @@ export const actions = {
);
if (haveMoldingQuestionAnswersChanged) {
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
context.commit(storeMutations.UPDATE_CAPABILITY_QUESTION_ANSWERS, null);
@ -1766,6 +1821,8 @@ export const actions = {
);
if (haveCapabilityQuestionAnswersChanged) {
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
context.commit(storeMutations.UPDATE_GLASS_PARTS, null);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, null);
}
@ -1787,8 +1844,7 @@ export const actions = {
saveSupportingItems(context, supportingItems) {
if (!deepEqual(supportingItems, context.state.order.lineItems.supportingItems)) {
context.commit(storeMutations.RESET_SERVICE_LOCATION_APPOINTMENT_TYPE);
context.commit(storeMutations.RESET_SERVICE_LOCATION_PROVIDER);
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
}
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, supportingItems);
@ -1868,15 +1924,29 @@ export const actions = {
context.state.order.serviceLocation &&
serviceZipCodeInfo.zipCode !== context.state.order.serviceLocation.zipCode
) {
context.commit(storeMutations.RESET_SERVICE_LOCATION_APPOINTMENT_TYPE);
context.commit(storeMutations.RESET_SERVICE_LOCATION_PROVIDER);
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
context.commit(storeMutations.RESET_SERVICE_LOCATION_MOBILE_ADDRESS);
}
context.commit(storeMutations.UPDATE_SERVICE_LOCATION, serviceZipCodeInfo);
context.commit(storeMutations.UPDATE_SERVICE_ZIP, serviceZipCodeInfo);
},
saveServiceLocation(context, serviceLocationInfo) {
if (context.state.order.serviceLocation) {
if (
serviceLocationInfo.zipCode !== context.state.order.serviceLocation.zipCode ||
!providersEqual(
serviceLocationInfo.provider,
context.state.order.serviceLocation.provider
) ||
serviceLocationInfo.appointmentType !==
context.state.order.serviceLocation.appointmentType
) {
context.commit(storeMutations.RESET_SCHEDULE);
}
}
context.commit(storeMutations.UPDATE_SERVICE_LOCATION, serviceLocationInfo);
},
@ -1889,7 +1959,6 @@ export const actions = {
if (vehicleInfo.vin !== context.state.order.vehicle.vin) {
if (!isSelectedGlassAvailableForVehicle) {
context.dispatch(storeActions.RESET_DAMAGE_STATE_AND_DEPENDENCIES);
context.dispatch(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
}
//Save new values
@ -1899,8 +1968,7 @@ export const actions = {
saveGlassParts(context, parts) {
if (!deepEqual(parts, context.state.order.lineItems.glassParts)) {
context.commit(storeMutations.RESET_SERVICE_LOCATION_APPOINTMENT_TYPE);
context.commit(storeMutations.RESET_SERVICE_LOCATION_PROVIDER);
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
}
context.commit(storeMutations.UPDATE_GLASS_PARTS, parts);
@ -1915,6 +1983,19 @@ export const actions = {
},
isVinOptionalVehicle(context) {
//Optional for carIds with only a single windshield
if (
singleWindshieldCarIds.find((item) => item === context.state.order.vehicle.carId) &&
context.state.order.damage.glassToReplace.length == 1 &&
context.state.order.damage.glassToReplace.find(
(glassToReplace) =>
glassToReplace.glassLocation.toLowerCase() ===
damageLocationsSelected.WINDSHIELD.toLowerCase()
)
) {
return true;
}
//Optional for specific YMMSs
switch (context.state.order.vehicle.make.toLowerCase()) {
case "mercedes benz":
case "volkswagen":
@ -1923,14 +2004,12 @@ export const actions = {
return true;
default:
}
if (
context.state.order.vehicle.make.toLowerCase() === "ford" &&
context.state.order.vehicle.year >= 2018
) {
return true;
}
if (
context.state.order.vehicle.make.toLowerCase() === "bmw" &&
context.state.order.vehicle.year <= 2017
@ -2087,6 +2166,17 @@ function convertGlassPieceToBackEndCompatibleFormat(glassPieces) {
});
}
function providersEqual(providerA, providerB) {
return (
providerA.providerNumber === providerB.providerNumber &&
providerA.address?.city === providerB.address?.city &&
providerA.address?.state === providerB.address?.state &&
providerA.address?.streetAddress === providerB.address?.streetAddress &&
providerA.address?.zipCode === providerB.address?.zipCode
);
//TODO: Change back to deepEqual once zipCodeCtu is added to saveSession.
}
// This function will verify schedule info is still valid.
// check to see if we have an appointment date on the order object.
// if so, make sure it's not in the past. if in the past, clear schedule info in store.

View file

@ -406,14 +406,16 @@ describe("Actions", () => {
it("lookupVinByImage action, should return list of vins", async () => {
// Arrange
const context = state;
const dummyImage = {};
const image = new File([], "test.jpg", {
type: "image/jpeg",
});
globalMethods.callHttpClient.mockImplementation(() => {
return Promise.resolve({ data: ["1C6JJTAG3NL134044"] });
});
// Act
const response = await actions.lookupVinByImage(context, dummyImage);
const response = await actions.lookupVinByImage(context, image);
// Assert
expect(response.data).toEqual(["1C6JJTAG3NL134044"]);
@ -422,7 +424,9 @@ describe("Actions", () => {
it("lookupVinByImage action, should reject if error in calling API", async () => {
// Arrange
const context = state;
const dummyImage = {};
const image = new File([], "test.jpg", {
type: "image/jpeg",
});
globalMethods.callHttpClient.mockImplementation(() => {
return Promise.reject("An error occurred");
@ -431,9 +435,7 @@ describe("Actions", () => {
// Act
// Assert
await expect(actions.lookupVinByImage(context, dummyImage)).rejects.toEqual(
"An error occurred"
);
await expect(actions.lookupVinByImage(context, image)).rejects.toEqual("An error occurred");
});
it("getVehicleMakes action, should return makes list", async () => {
@ -547,41 +549,48 @@ describe("Actions", () => {
// Arrange
const context = state;
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
await actions.resetDamageAndDependencies(context);
expect(commit).toBeCalledWith(storeMutations.RESET_DAMAGE_STATE);
expect(commit).toBeCalledWith(storeMutations.RESET_GLASS_PARTS_STATE);
expect(dispatch).toBeCalledWith(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
});
it("resetRegistrationAndDependencies action", async () => {
// Arrange
const context = state;
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
await actions.resetRegistrationAndDependencies(context);
expect(commit).toBeCalledWith(storeMutations.RESET_REGISTRATION_STATE);
expect(commit).toBeCalledWith(storeMutations.RESET_GLASS_PARTS_STATE);
expect(dispatch).toBeCalledWith(storeActions.RESET_PARTS_STATE_AND_DEPENDENCIES);
});
it("resetPartsAndDependencies action", async () => {
// Arrange
const context = state;
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
await actions.resetPartsAndDependencies(context);
expect(commit).toBeCalledWith(storeMutations.RESET_GLASS_PARTS_STATE);
expect(dispatch).toBeCalledWith(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
});
it("resetState action", async () => {
@ -933,6 +942,90 @@ describe("Actions", () => {
);
});
it("saveServiceZipCodeInfo, should call mutation and save zip code to state", () => {
// Arrange
const context = state;
const commit = jest.fn();
context.commit = commit;
const serviceZipCodeInfo = {
zipCode: "43212",
};
// Act
actions.saveServiceLocation(context, serviceZipCodeInfo);
// Assert
expect(commit).toBeCalledWith(storeMutations.UPDATE_SERVICE_LOCATION, serviceZipCodeInfo);
expect(state.order.serviceLocation.zipCode).toEqual("43212");
});
it("saveServiceZipCodeInfo, should reset if zip code is different", () => {
// Arrange
const context = {
state: {
order: {
serviceLocation: {
zipCode: "43212",
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
const serviceZipCodeInfo = {
zipCode: "43202",
};
// Act
actions.saveServiceZipCodeInfo(context, serviceZipCodeInfo);
// Assert
expect(dispatch).toHaveBeenCalledWith(
storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES
);
expect(commit).toHaveBeenCalledWith(storeMutations.RESET_SERVICE_LOCATION_MOBILE_ADDRESS);
});
it("saveServiceZipCodeInfo, should not reset if zip code is the same", () => {
// Arrange
const context = {
state: {
order: {
serviceLocation: {
zipCode: "43212",
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
const serviceZipCodeInfo = {
zipCode: "43212",
};
// Act
actions.saveServiceZipCodeInfo(context, serviceZipCodeInfo);
// Assert
expect(dispatch).not.toHaveBeenCalledWith(
storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES
);
expect(commit).not.toHaveBeenCalledWith(
storeMutations.RESET_SERVICE_LOCATION_MOBILE_ADDRESS
);
});
it("saveServiceLocation, should call mutation and save service address to state", () => {
// Arrange
const context = state;
@ -959,6 +1052,250 @@ describe("Actions", () => {
expect(state.order.serviceLocation.zipCodeCtu).toEqual("01820");
});
it("saveServiceLocation, should reset if zipcode is different", () => {
// Arrange
const context = {
state: {
order: {
serviceLocation: {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
const serviceLocation = {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43202",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
};
// Act
actions.saveServiceLocation(context, serviceLocation);
// Assert
expect(commit).toHaveBeenCalledWith(storeMutations.RESET_SCHEDULE);
});
it("saveServiceLocation, should reset if provider is different", () => {
// Arrange
const context = {
state: {
order: {
serviceLocation: {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
const serviceLocation = {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "22222",
address: {
streetAddress: "321 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
};
// Act
actions.saveServiceLocation(context, serviceLocation);
// Assert
expect(commit).toHaveBeenCalledWith(storeMutations.RESET_SCHEDULE);
});
it("saveServiceLocation, should reset if appointment type is different", () => {
// Arrange
const context = {
state: {
order: {
serviceLocation: {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
const serviceLocation = {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Inshop",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
};
// Act
actions.saveServiceLocation(context, serviceLocation);
// Assert
expect(commit).toHaveBeenCalledWith(storeMutations.RESET_SCHEDULE);
});
it("saveServiceLocation, should not reset if parameters are the same", () => {
// Arrange
const context = {
state: {
order: {
serviceLocation: {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
const serviceLocation = {
address: "123 Test Lane",
city: "Columbus",
zipCode: "43212",
state: "OH",
zipCodeCtu: "01820",
appointmentType: "Mobile",
isVehicleProtected: true,
provider: {
providerNumber: "11111",
address: {
streetAddress: "123 Test Lane",
city: "Columbus",
state: "OH",
zipCode: "43212",
zipCodeCtu: "01820",
},
},
};
// Act
actions.saveServiceLocation(context, serviceLocation);
// Assert
expect(commit).not.toHaveBeenCalledWith(storeMutations.RESET_SCHEDULE);
});
it("saveGlassParts, should call mutation", () => {
// Arrange
const context = {
@ -966,14 +1303,88 @@ describe("Actions", () => {
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
actions.saveGlassParts(context, { glassParts: {} });
// Assert
expect(commit).toBeCalledWith(storeMutations.UPDATE_GLASS_PARTS, { glassParts: {} });
expect(dispatch).toBeCalledWith(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
});
it("saveSupportingItems, should call mutations", () => {
// Arrange
const context = {
state: state,
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
actions.saveSupportingItems(context, []);
// Assert
expect(commit).toBeCalledWith(storeMutations.UPDATE_SUPPORTING_ITEMS, []);
});
it("saveSupportingItems, should call reset logic when value is new", () => {
// Arrange
const context = {
state: {
order: {
lineItems: {
supportingItems: ["TestValue1", "TestValue2"],
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
actions.saveSupportingItems(context, ["TestValue3", "TestValue4", "TestValue5"]);
// Assert
expect(dispatch).toBeCalledWith(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
});
it("saveSupportingItems, should not call reset logic when value is the same", () => {
// Arrange
const context = {
state: {
order: {
lineItems: {
supportingItems: ["TestValue1", "TestValue2"],
},
},
},
};
const commit = jest.fn();
const dispatch = jest.fn();
context.commit = commit;
context.dispatch = dispatch;
// Act
actions.saveSupportingItems(context, ["TestValue1", "TestValue2"]);
// Assert
expect(dispatch).not.toBeCalledWith(
storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES
);
});
it("clearVin, should call mutation", () => {
@ -2808,6 +3219,36 @@ describe("isVinOptionalVehicle", () => {
},
};
var vinOptionalResult = actions.isVinOptionalVehicle(context);
expect(vinOptionalResult).toEqual(expectedVinSkip);
}
);
const testcarID = [
["CR00000100", "make", [{ glassLocation: "driver" }], false],
["CR00067899", "make2", [{ glassLocation: "windshield" }], true],
[
"CR00062396",
"make3",
[{ glassLocation: "windshield" }, { glassLocation: "driver" }],
false,
],
["CR00066428", "make4", [{ glassLocation: "rear" }], false],
];
test.each(testcarID)(
"%s %s %o should skip vin lookup is %s",
async (carId, make, glassLocation, expectedVinSkip) => {
const context = state;
context.state = {
order: {
vehicle: { make: make, carId: carId },
damage: {
glassToReplace: glassLocation,
},
},
};
var vinOptionalResult = actions.isVinOptionalVehicle(context);
expect(vinOptionalResult).toEqual(expectedVinSkip);
}

View file

@ -26,8 +26,7 @@
@click-event="
$emit('textLinkClicked', getRouterLinkRouteFromCopy(copy))
"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
aria-label="Modal window" />
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)" />
</span>
<span v-else v-html="copy"></span>
</template>