Merge branch 'develop' into feature/csr-1465

This commit is contained in:
Chloe Herd 2023-10-13 15:38:10 -04:00
commit 655360b0c8
35 changed files with 2172 additions and 183 deletions

View file

@ -27,7 +27,7 @@ module.exports = {
testMatch: ["**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)"],
coverageThreshold: {
global: {
statements: 77,
statements: 75,
},
},
// Uncomment this to avoid the massive amount of warnings we are getting for onSubmit and onInvalidSubmit

View file

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<g fill="none" fill-rule="evenodd">
<g fill-rule="nonzero">
<g>
<g>
<path fill="#FBBB00" d="M4.432 12.086l-.696 2.6-2.544.053C.432 13.329 0 11.715 0 10c0-1.658.403-3.222 1.118-4.599l2.266.415.992 2.252c-.207.606-.32 1.256-.32 1.932 0 .734.132 1.437.376 2.086z" transform="translate(-119 -800) translate(104 753) translate(15 47)"/>
<path fill="#518EF8" d="M19.825 8.132C19.94 8.737 20 9.362 20 10c0 .716-.075 1.414-.219 2.088-.486 2.292-1.758 4.294-3.52 5.71h-.001l-2.853-.146-.404-2.52c1.169-.686 2.083-1.76 2.564-3.044H10.22V8.132h9.605z" transform="translate(-119 -800) translate(104 753) translate(15 47)"/>
<path fill="#28B446" d="M16.26 17.798C14.547 19.176 12.37 20 10 20c-3.808 0-7.12-2.129-8.808-5.261l3.24-2.653c.845 2.254 3.02 3.859 5.568 3.859 1.096 0 2.122-.297 3.003-.814l3.257 2.667z" transform="translate(-119 -800) translate(104 753) translate(15 47)"/>
<path fill="#F14336" d="M16.383 2.302l-3.24 2.652c-.911-.57-1.989-.899-3.143-.899-2.607 0-4.821 1.678-5.624 4.013L1.12 5.401C2.781 2.192 6.134 0 10 0c2.426 0 4.651.864 6.383 2.302z" transform="translate(-119 -800) translate(104 753) translate(15 47)"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" viewBox="0 0 22 20">
<g fill="none" fill-rule="evenodd">
<g fill="#0072C6" fill-rule="nonzero">
<g>
<g>
<path d="M12.794 4.525v3.677l1.37.809c.036.01.114.01.15 0l5.899-3.728c0-.441-.44-.758-.687-.758h-6.732z" transform="translate(-119 -870) translate(104 753) translate(15 117)"/>
<path d="M12.794 9.574l1.25.805c.176.121.389 0 .389 0-.212.121 5.78-3.61 5.78-3.61v6.757c0 .736-.502 1.044-1.067 1.044h-6.353V9.574z" transform="translate(-119 -870) translate(104 753) translate(15 117)"/>
<g>
<path d="M6.266 7.736c-.427 0-.766.187-1.017.562-.25.375-.376.871-.376 1.489 0 .627.125 1.122.376 1.487.25.365.58.546.986.546.42 0 .753-.177.998-.532.246-.354.37-.846.37-1.475 0-.656-.12-1.166-.359-1.531-.238-.364-.564-.546-.978-.546z" transform="translate(-119 -870) translate(104 753) translate(15 117) translate(.052 .143)"/>
<path d="M.332 2.515v14.383l11.671 2.295V.397L.333 2.515zm7.81 9.623c-.493.609-1.136.914-1.93.914-.773 0-1.403-.295-1.89-.885-.486-.59-.73-1.358-.73-2.305 0-1 .247-1.81.741-2.427.495-.618 1.15-.927 1.966-.927.77 0 1.393.295 1.869.887.476.591.713 1.37.713 2.338.001.995-.246 1.797-.739 2.405z" transform="translate(-119 -870) translate(104 753) translate(15 117) translate(.052 .143)"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20">
<g fill="none" fill-rule="evenodd">
<g fill="#DBA702" fill-rule="nonzero">
<g>
<g>
<path d="M12.394 4.525v3.677l1.327.809c.035.01.111.01.146 0l5.714-3.728c0-.441-.425-.758-.665-.758h-6.522z" transform="translate(-119 -835) translate(104 753) translate(15 82)"/>
<path d="M12.394 9.574l1.211.805c.171.121.377 0 .377 0-.205.121 5.6-3.61 5.6-3.61v6.757c0 .736-.487 1.044-1.034 1.044h-6.155V9.574z" transform="translate(-119 -835) translate(104 753) translate(15 82)"/>
<g>
<path d="M6.07 7.736c-.413 0-.742.187-.985.562-.243.375-.365.871-.365 1.489 0 .627.122 1.122.365 1.487s.562.546.956.546c.406 0 .728-.177.966-.532.238-.354.358-.846.358-1.475 0-.656-.116-1.166-.347-1.531-.231-.364-.547-.546-.948-.546z" transform="translate(-119 -835) translate(104 753) translate(15 82) translate(.05 .143)"/>
<path d="M.322 2.515v14.383l11.306 2.295V.397L.322 2.515zm7.566 9.623c-.478.609-1.101.914-1.87.914-.75 0-1.359-.295-1.83-.885-.472-.59-.709-1.358-.709-2.305 0-1 .24-1.81.719-2.427.48-.618 1.114-.927 1.904-.927.746 0 1.35.295 1.81.887.461.591.692 1.37.692 2.338 0 .995-.239 1.797-.716 2.405z" transform="translate(-119 -835) translate(104 753) translate(15 82) translate(.05 .143)"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" viewBox="0 0 22 20">
<g fill="none" fill-rule="evenodd">
<g fill="#5F01D1" fill-rule="nonzero">
<g>
<g>
<g>
<path d="M0 4.868h4.29l2.498 6.39 2.53-6.39h4.177l-6.29 15.129H3.003l1.722-4.01L0 4.869zm18.358 5.106H13.68L17.832 0h4.661l-4.135 9.974zm-3.452.955c1.435 0 2.599 1.163 2.599 2.599 0 1.435-1.164 2.598-2.6 2.598-1.434 0-2.598-1.163-2.598-2.598 0-1.436 1.164-2.6 2.599-2.6z" transform="translate(-119 -905) translate(104 753) translate(15 152) translate(0 .11)"/>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 708 B

View file

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="20" viewBox="0 0 17 20">
<g fill="none" fill-rule="evenodd">
<g fill="#606060" fill-rule="nonzero">
<g>
<g>
<path d="M5.44 20C2.463 19.983 0 13.907 0 10.812c0-5.055 3.793-6.161 5.254-6.161.659 0 1.362.258 1.982.487.434.16.883.325 1.132.325.15 0 .501-.14.812-.264.663-.263 1.487-.591 2.447-.591h.006c.717 0 2.89.157 4.198 2.12l.306.46-.44.332c-.63.475-1.778 1.341-1.778 3.058 0 2.032 1.3 2.814 1.925 3.19.276.166.561.337.561.71 0 .245-1.95 5.492-4.782 5.492-.692 0-1.182-.209-1.614-.392-.437-.186-.815-.346-1.438-.346-.316 0-.715.149-1.138.307-.578.216-1.232.461-1.974.461h-.02zM11.927 0C12.001 2.66 10.1 4.504 8.2 4.388 7.887 2.266 10.1 0 11.927 0z" transform="translate(-119 -763) translate(104 753) translate(15 10)"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 907 B

View file

@ -0,0 +1,8 @@
const calendarOptions = {
ICAL: "iCal",
GOOGLE: "Google",
OUTLOOK: "Outlook",
OUTLOOKCOM: "Outlook.com",
YAHOO: "Yahoo",
};
export { calendarOptions };

View file

@ -0,0 +1,7 @@
const calendarStatus = {
FREE: "Free",
BUSY: "Busy",
TENTATIVE: "Tentative",
OUT_OF_THE_OFFICE: "OutOfTheOffice",
};
export { calendarStatus };

View file

@ -138,6 +138,18 @@ const endpoints = {
url: "/price/api/v1/price/order-items",
method: "GET",
},
ValidatePromo: {
url: "/price/api/v1/price/order-promo",
method: "POST",
},
RevalidatePromos: {
url: "/price/api/v1/price/revalidated-order-promos",
method: "POST",
},
TaxOrderItems: {
url: "/price/api/v1/price/taxed-order-items",
method: "GET",
},
LogExperimentExposureIfAssigned: {
url: "/experiments/api/v1/experiments/log-exposure",
method: "POST",

View file

@ -41,6 +41,7 @@ const storeActions = {
UPDATE_STORE_WITH_SAVE_SESSION_RESPONSE: "updateStoreWithSaveSessionResponse",
VALIDATE_ZIP: "validateZip",
PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "priceOrderItemsAndSaveServerData",
TAX_ORDER_ITEMS_AND_SAVE_SERVER_DATA: "taxOrderItemsAndSaveServerData",
LOG_EXPERIMENT_EXPOSURE: "logExperimentExposure",
LOG_PAGE_VIEW: "logPageView",
LOG_CUSTOM_EVENT: "logCustomEvent",
@ -50,6 +51,8 @@ const storeActions = {
CLEAR_VIN: "clearVin",
RESET_SAVE_SESSION_PROMISE: "resetSaveSessionPromise",
GET_SIGNATURE: "getSignature",
VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA: "validateOrderPromoAndSaveServerData",
REVALIDATE_ORDER_PROMOS_AND_UPDATE_STORE: "revalidateOrderPromosAndUpdateStore",
// DEPENDENCY MUTATIONS
RESET_DAMAGE_STATE_AND_DEPENDENCIES: "resetDamageAndDependencies",
@ -83,6 +86,7 @@ const storeActions = {
SAVE_SUPPORTING_ITEMS_SUPPRESSING_STATE_RESETTING:
"saveSupportingItemsSuppressingStateResetting",
SAVE_VAPS: "saveVaps",
SAVE_PROMOS: "savePromos",
SAVE_CUSTOMER_DETAILS: "saveCustomerDetails",
SAVE_SERVICE_LOCATION_TECH_NOTES: "saveServiceLocationTechNotes",
SAVE_WORK_ORDER_FLAG: "saveWorkOrderFlag",

View file

@ -26,6 +26,8 @@ const storeMutations = {
UPDATE_VAPS: "updateVaps",
UPDATE_SUPPORTING_ITEMS: "updateSupportingItems",
UPDATE_LINE_ITEMS_SERVER_DATA: "updateLineItemsServerData",
UPDATE_INACTIVE_PROMOS: "updateInactivePromos",
UPDATE_PROMOS: "updatePromos",
UPDATE_REGISTRATION: "updateRegistration",

View file

@ -21,9 +21,8 @@ export default {
name: "add-vaps-modal-buttons",
props: {
cartItems: Object,
rainDefenseItem: Object,
wiperItems: Object,
vapsTilesCmsName: String,
availableLineItems: Object,
},
data() {
return {
@ -55,13 +54,19 @@ export default {
});
},
rainDefensePrice() {
const lineItem = this.rainDefenseItem;
// const lineItem = this.rainDefenseItem;
const lineItem = this.availableLineItems.find((item) => {
return item.partType === "RAIN DEFENSE";
});
const price = lineItem.laborAmount + lineItem.sellingPrice + lineItem.kitPrice;
return parseFloat(price).toFixed(2);
},
wipersPrice() {
let price = 0;
const lineItems = this.wiperItems;
const lineItems = this.availableLineItems.filter((item) => {
return item.partType.includes(" WIPER");
});
lineItems?.forEach((item) => {
price += item.laborAmount + item.sellingPrice + item.kitPrice;
});

View file

@ -54,36 +54,28 @@
</template>
<script>
import cart from "@/fmg-components/cart/cart";
import baseMixin from "@/mixins/base-mixin.js";
import funnelSubHeader from "@/fmg-components/funnel-sub-header/funnel-sub-header";
import { packageNames } from "@/constants/package-names";
import {
getHighestFullySatisfiedTier,
containsLineItemWithPartType,
getPackageContents,
findLineItemsWithPartType,
} from "@/helpers/service-package-helper";
import reviewBlock from "@/layouts/review/review-block/review-block";
import textLink from "@/ux-components/text-link/text-link";
import textBlock from "@/digital-components/text-block/text-block";
export default {
name: "modal",
props: {
headerCmsWidgetName: String,
servicePackageOptionsCmsName: String,
damage: Object,
storeLineItems: Object,
servicePackageOptionsCmsName: String,
availableLineItems: Object,
allowableCartItems: Object,
lineItems: Object,
},
data() {
return {
isActive: false,
//availableVaps: [], TO DO Do we need this?
packageWithVAPS: false,
// packageLevel: "",
amountDue: this.getAmountDue(),
};
},
@ -91,11 +83,6 @@ export default {
toggleClass: function (event) {
this.isActive = !this.isActive;
},
initializeComponent(apiResponses) {
//TO DO Do we need this?
// const vapsFromApi = [...apiResponses.wipers, apiResponses.rainDefense];
// this.availableVaps = vapsFromApi;
},
getPackagePriceString(packageName) {
const formattedPriceFloat = parseFloat(this.getPackagePrice(packageName)).toFixed(2);
return (this.isInsuranceSelected ? "As little as $" : "$") + formattedPriceFloat;
@ -104,7 +91,7 @@ export default {
let priceFloat = this.isInsuranceSelected
? 0
: baseMixin.methods.getTierOnePackagePrice(
baseMixin.methods.filterOutFees(this.nullSafeAvailableLineItems)
baseMixin.methods.filterOutFees(this.availableLineItems)
);
priceFloat += this.getVapsPrice(packageName);
@ -131,7 +118,7 @@ export default {
getVapsLineItemsForSelectedPackage(packageName) {
const packageContentTypes = getPackageContents(
this.glassToReplace,
this.nullSafeAvailableLineItems,
this.availableLineItems,
this.isRepair,
packageName
);
@ -140,10 +127,9 @@ export default {
packageContentTypes.forEach((vapType) => {
vapsLineItemsForSelectedPackage.push(
...findLineItemsWithPartType(vapType, this.nullSafeAvailableLineItems)
...findLineItemsWithPartType(vapType, this.availableLineItems)
);
});
// console.log("VapsLineItems:", vapsLineItemsForSelectedPackage);
return vapsLineItemsForSelectedPackage;
},
},
@ -184,7 +170,7 @@ export default {
displayedPackageLineItems() {
const displayedPackageLineItems = [];
const packageLineItems = this.getVapsLineItemsForSelectedPackage(this.packageLevel);
//TO DO Could we get the same list from this.storeLineItems?
// TODO Determine if we could get the same list from this.lineItems?
packageLineItems?.forEach((item) => {
let price = item.sellingPrice + item.kitPrice + item.laborAmount;
const packageLineItem = {
@ -195,7 +181,6 @@ export default {
};
displayedPackageLineItems.push(packageLineItem);
});
// console.log("What displayedPackageLineItems is", packageLineItems);
return displayedPackageLineItems;
},
displayedNonPackageLineItems() {
@ -237,11 +222,11 @@ export default {
return this.damage.isRepair;
},
vaps() {
const vaps = this.storeLineItems?.vaps;
const vaps = this.lineItems?.vaps;
return vaps?.length > 0 ? vaps : [];
},
supportingItems() {
const supportingItems = this.storeLineItems?.supportingItems;
const supportingItems = this.lineItems?.supportingItems;
return supportingItems?.length > 0 ? supportingItems : [];
},
recycleLabel() {
@ -259,9 +244,6 @@ export default {
// }
return true;
},
nullSafeAvailableLineItems() {
return this.availableLineItems ?? [];
},
},
components: {
textBlock,

View file

@ -159,8 +159,8 @@ export default {
.modal {
&.menu-modal {
left: auto;
height: calc(100% - 72px);
top: 72px;
height: calc(100% - 56px);
top: 56px;
border-top: 1px solid $gray-300;
overflow-x: visible;
overflow-y: visible;

View file

@ -14,7 +14,10 @@
:displayGenericVehicleImage="false" />
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" />
</div>
</div>
<div class="row justify-content-md-center">
<div class="col-md-6 col-xl-4">
<alert
ref="alertFewMoreQuestions"
cmsWidgetName="alertWidget"

View file

@ -0,0 +1,76 @@
import { calendarStatus } from "@/constants/calendar-status";
import { getDateFormat } from "@/helpers/date-helper";
export function getCalendarFile(calFile) {
const dateFormat = "yyyyMMddTHHmmss";
var calEvent = [];
calEvent.push("BEGIN:VCALENDAR");
calEvent.push("VERSION:2.0");
calEvent.push("BEGIN:VEVENT");
calEvent.push("DTSTAMP:" + calFile.TimeStamp);
calEvent.push("UID:" + calFile.UniqueId + "@safelite.com");
calEvent.push("PRODID:noreply@safelite.com");
switch (calFile.Status) {
case calendarStatus.BUSY:
calEvent.push("X-MICROSOFT-CDO-BUSYSTATUS:BUSY");
break;
case calendarStatus.FREE:
calEvent.push("TRANSP:TRANSPARENT");
break;
case calendarStatus.TENTATIVE:
calEvent.push("STATUS:TENTATIVE");
break;
case calendarStatus.OUT_OF_THE_OFFICE:
calEvent.push("X-MICROSOFT-CDO-BUSYSTATUS:OOF");
break;
default:
//throw new Exception("Invalid CalendarStatus");
}
if (calFile.AllDayEvent) {
calEvent.push("DTSTART;VALUE=DATE:" + getDateFormat(calFile.StartDate, dateFormat));
calEvent.push("DTEND;;VALUE=DATE:" + getDateFormat(calFile.EndDate, dateFormat));
} else {
calEvent.push("DTSTART:" + getDateFormat(calFile.StartDate, dateFormat));
calEvent.push("DTEND:" + getDateFormat(calFile.EndDate, dateFormat));
}
calEvent.push("SUMMARY:" + ToCalendarFileString(calFile.Subject, " "));
if (calFile.Location != null && calFile.Location != "") {
calEvent.push("LOCATION:" + ToCalendarFileString(calFile.Location, " "));
}
if (calFile.Body != null && calFile.Body != "") {
if (calFile.IsHTML) {
calEvent.push("DESCRIPTION:" + ToCalendarFileString(calFile.Body));
calEvent.push(
"X-ALT-DESC;FMTTYPE=text/html:" +
'<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2//EN">' +
"<HTML><HEAD><TITLE></TITLE></HEAD><BODY>" +
'<P DIR=LTR><SPAN LANG="en-us">' +
"</SPAN>" +
ToCalendarFileString(calFile.Body) +
"</BODY></HTML>"
);
} else {
calEvent.push("DESCRIPTION:" + ToCalendarFileString(calFile.Body));
}
}
calEvent.push("END:VEVENT");
calEvent.push("END:VCALENDAR");
return calEvent.join("\r\n");
}
function ToCalendarFileString(str, replacementArg = "<p>") {
return str.replace("\r\n", replacementArg);
}
export function download(filename, fileBody) {
var element = document.createElement("a");
element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(fileBody));
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}

View file

@ -0,0 +1,79 @@
import { download, getCalendarFile } from "./add-to-calendar-helper";
// Create a mock function
const mockDownload = jest.fn((filename, fileBody) => {
// Simulate creating an anchor element
const mockElement = {
setAttribute: jest.fn(),
style: { display: "none" },
click: jest.fn(),
};
// Mock the document.createElement method
document.createElement = jest.fn(() => mockElement);
// Mock the document.body.appendChild method
document.body.appendChild = jest.fn();
// Mock the document.body.removeChild method
document.body.removeChild = jest.fn();
// Call the original function with the mock element
download(filename, fileBody);
// Return the mock element for further assertions
return mockElement;
});
// Use the mock function in a test
test("download function", () => {
// Call the mock function with some arguments
const mockElement = mockDownload("test.txt", "Hello world");
// Make assertions about the mock function and the mock element
expect(mockDownload).toHaveBeenCalledTimes(1);
expect(mockDownload).toHaveBeenCalledWith("test.txt", "Hello world");
expect(mockElement.setAttribute).toHaveBeenCalledTimes(2);
expect(mockElement.setAttribute).toHaveBeenCalledWith(
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent("Hello world")
);
expect(mockElement.setAttribute).toHaveBeenCalledWith("download", "test.txt");
expect(mockElement.style.display).toBe("none");
expect(mockElement.click).toHaveBeenCalledTimes(1);
expect(document.body.appendChild).toHaveBeenCalledTimes(1);
expect(document.body.appendChild).toHaveBeenCalledWith(mockElement);
expect(document.body.removeChild).toHaveBeenCalledTimes(1);
expect(document.body.removeChild).toHaveBeenCalledWith(mockElement);
});
// Use the mock function in a test
test("getCalendarFile function", () => {
// Create a mock calFile object
const mockCalFile = {
UniqueId: "123456",
Status: "Busy",
StartDate: new Date(2023, 9, 11, 12, 0, 0),
EndDate: new Date(2023, 9, 11, 13, 0, 0),
Subject: "Meeting with John",
Location: "Conference Room",
Body: "Discuss the project progress",
TimeStamp: "20231011T113118",
};
// Call the mock function with the mock calFile object
const mockCalendarFile = getCalendarFile(mockCalFile);
// Make assertions about the mock function and the mock calendar file
expect(mockCalendarFile).toContain("BEGIN:VCALENDAR");
expect(mockCalendarFile).toContain("VERSION:2.0");
expect(mockCalendarFile).toContain("BEGIN:VEVENT");
expect(mockCalendarFile).toContain("DTSTAMP:20231011T113118");
expect(mockCalendarFile).toContain("UID:123456@safelite.com");
expect(mockCalendarFile).toContain("PRODID:noreply@safelite.com");
expect(mockCalendarFile).toContain("DTSTART:20231011T120000");
expect(mockCalendarFile).toContain("DTEND:20231011T130000");
expect(mockCalendarFile).toContain("X-MICROSOFT-CDO-BUSYSTATUS:BUSY");
expect(mockCalendarFile).toContain("SUMMARY:Meeting with John");
expect(mockCalendarFile).toContain("LOCATION:Conference Room");
expect(mockCalendarFile).toContain("DESCRIPTION:Discuss the project progress");
expect(mockCalendarFile).toContain("END:VEVENT");
expect(mockCalendarFile).toContain("END:VCALENDAR");
});

View file

@ -1,36 +1,27 @@
export function getDateDifferenceInDays(startDate, endDate) {
// Create date objects from the input strings
var date1 = new Date(endDate);
date1.setHours(0, 0, 0, 0);
var date2 = new Date(startDate);
date2.setHours(0, 0, 0, 0);
// To calculate the time difference of two dates
var Difference_In_Time = date1.getTime() - date2.getTime();
// To calculate the no. of days between two dates
return Difference_In_Time / (1000 * 3600 * 24);
// Use the built-in method to get the difference in milliseconds
var difference = date1 - date2;
// Convert milliseconds to days and return the result
return difference / (1000 * 3600 * 24);
}
export function getFullDayName(date) {
if (date instanceof Date !== true) return;
const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
return days[date.getDay()];
// Use a ternary operator to check if the input is a valid date object
return date instanceof Date
? // Use the built-in method toLocaleDateString() to get the full day name in the current locale
date.toLocaleDateString(undefined, { weekday: "long" })
: // Return undefined if the input is not a valid date object
undefined;
}
export function getFullMonthName(date) {
if (date instanceof Date !== true) return;
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return monthNames[date.getMonth()];
// Use a ternary operator to check if the input is a valid date object
return date instanceof Date
? // Use the built-in method toLocaleDateString() to get the full month name in the current locale
date.toLocaleDateString(undefined, { month: "long" })
: // Return undefined if the input is not a valid date object
undefined;
}
export function get12HourTimeFormat(time) {
// Check correct time format and split into components
@ -62,3 +53,66 @@ export function get12HourTimeMobileFormat(time) {
}
return time.join(""); // return adjusted time or original string
}
export function getDateFormat(date, format) {
if (date instanceof Date !== true) return;
const year = date.getFullYear();
const month = ("0" + (date.getMonth() + 1)).slice(-2);
const day = ("0" + date.getDate()).slice(-2);
const hour = ("0" + date.getHours()).slice(-2);
const minute = ("0" + date.getMinutes()).slice(-2);
const second = ("0" + date.getSeconds()).slice(-2);
return format
.replace("yyyy", year)
.replace("MM", month)
.replace("dd", day)
.replace("hh", hour)
.replace("HH", hour)
.replace("mm", minute)
.replace("ss", second);
}
export function calculateDuration(startDate, endDate) {
if (startDate instanceof Date !== true) return;
if (endDate instanceof Date !== true) return;
return convertMsToTime(endDate - startDate);
}
export function convertMsToTime(milliseconds) {
let seconds = Math.floor(milliseconds / 1000);
let minutes = Math.floor(seconds / 60);
let hours = Math.floor(minutes / 60);
seconds = seconds % 60;
minutes = minutes % 60;
//commenting to get 24 time format
//hours = hours % 24;
return `${padTo2Digits(hours)}${padTo2Digits(minutes)}`;
}
export function padTo2Digits(time) {
// Use the built-in method toString() with a radix of 10 to convert the time value to a decimal string
time = time.toString(10);
// Use the conditional operator to check if the length of the string is less than 2
return time.length < 2
? // If yes, prepend a '0' to the string and return it
"0" + time
: // If no, return the original string
time;
}
export function combineDateAndTime(date, time) {
// Use the Date.parse() method to convert the date and time strings to a numeric value
var timestamp = Date.parse(date + "T" + time);
// Use the new Date() constructor to create a new date object from the numeric value
var newDate = new Date(timestamp);
// Return the new date object
return newDate;
}
export function addMinutes(date, minutes) {
return new Date(date.getTime() + minutes * 60000);
}
export function shortTimeString(date) {
// Use a ternary operator to check if the input is a valid date object
return date instanceof Date
? // Use the built-in method toLocaleTimeString() to get the short time string in the current locale
date.toLocaleTimeString(undefined, { hour: "numeric", minute: "numeric", hour12: true })
: // Return undefined if the input is not a valid date object
undefined;
}

View file

@ -0,0 +1,202 @@
import {
getFullDayName,
getFullMonthName,
get12HourTimeFormat,
get12HourTimeMobileFormat,
getDateFormat,
getDateDifferenceInDays,
calculateDuration,
padTo2Digits,
combineDateAndTime,
addMinutes,
shortTimeString,
} from "./date-helper";
describe("date-helper.js", () => {
it("get12HourTimeMobileFormat should return time in expected 12 hour mobile format.", () => {
// Define some sample times and their expected 12-hour formats
const testCases = [
{ time: "00:00", expected: "12 AM" },
{ time: "01:23", expected: "1:23 AM" },
{ time: "11:59", expected: "11:59 AM" },
{ time: "12:00", expected: "12 PM" },
{ time: "13:45", expected: "1:45 PM" },
{ time: "23:59", expected: "11:59 PM" },
];
// Loop through the test cases and call the function with each time
for (let testCase of testCases) {
// Call the function and store the result
const result = get12HourTimeMobileFormat(testCase.time);
// Assert that the result is equal to the expected 12-hour format
expect(result).toEqual(testCase.expected);
}
});
// Test if the function returns the correct 12-hour format for some sample times
it("should return the correct 12-hour format", function () {
// Define some sample times and their expected 12-hour formats
const testCases = [
{ time: "00:00", expected: "12:00 AM" },
{ time: "01:23", expected: "1:23 AM" },
{ time: "11:59", expected: "11:59 AM" },
{ time: "12:00", expected: "12:00 PM" },
{ time: "13:45", expected: "1:45 PM" },
{ time: "23:59", expected: "11:59 PM" },
];
// Loop through the test cases and call the function with each time
for (let testCase of testCases) {
// Call the function and store the result
const result = get12HourTimeFormat(testCase.time);
// Assert that the result is equal to the expected 12-hour format
expect(result).toEqual(testCase.expected);
}
});
// it("getFullMonthName should return full month format.", () => {
// // Arrange / Act
// const date = new Date("2023-10-01");
// const monthName = getFullMonthName(date);
// // Assert
// expect(monthName).toEqual("October");
// });
// it("getFullDayName should return full Day Name format.", () => {
// // Arrange / Act
// const date = new Date("2023-10-01");
// const dayName = getFullDayName(date);
// // Assert
// expect(dayName).toEqual("Sunday");
// });
// it("getDateFormat should return date in the given format.", () => {
// // Arrange / Act
// const date = new Date("2023-10-01");
// const format = "yyyy-MM-dd";
// const formattedDate = getDateFormat(date, format);
// // Assert
// expect(formattedDate).toEqual("2023-10-01");
// });
it("should return the correct difference in days", function () {
// Define some sample dates and their expected differences
const testCases = [
{ startDate: "2023-10-12", endDate: "2023-10-15", expected: 3 },
{ startDate: "2023-01-01", endDate: "2023-01-31", expected: 30 },
{ startDate: "2022-12-31", endDate: "2023-01-01", expected: 1 },
{ startDate: "2023-02-28", endDate: "2023-03-01", expected: 1 },
{ startDate: "2023-03-01", endDate: "2023-02-28", expected: -1 },
];
// Loop through the test cases and call the function with each pair of dates
for (let testCase of testCases) {
// Call the function and store the result
const result = getDateDifferenceInDays(testCase.startDate, testCase.endDate);
// Assert that the result is equal to the expected difference
expect(result).toEqual(testCase.expected);
}
});
it("calculateDuration should return duration in hhmm format.", () => {
// Arrange / Act
const startDate = new Date("2023-10-01 10:00:00");
const endDate = new Date("2023-10-01 23:00:00");
const duration = calculateDuration(startDate, endDate);
// Assert
expect(duration).toEqual("1300");
});
it("padTo2Digits should return time in 2 digit format.", () => {
// Define some sample time values and their expected strings
const testCases = [
{ time: 0, expected: "00" },
{ time: 1, expected: "01" },
{ time: 9, expected: "09" },
{ time: 10, expected: "10" },
{ time: 59, expected: "59" },
];
// Loop through the test cases and call the function with each time value
for (let testCase of testCases) {
// Call the function and store the result
const result = padTo2Digits(testCase.time);
// Assert that the result is equal to the expected string
expect(result).toEqual(testCase.expected);
}
});
it("combineDateAndTime should return combined dateTime format.", () => {
// Define some sample inputs and their expected outputs
const testCases = [
{ date: "2023-10-12", time: "12:00", expected: new Date(2023, 9, 12, 12, 0) },
{ date: "2023-01-01", time: "00:00", expected: new Date(2023, 0, 1, 0, 0) },
{ date: "2022-12-31", time: "23:59", expected: new Date(2022, 11, 31, 23, 59) },
{ date: "2023-02-28", time: "13:45", expected: new Date(2023, 1, 28, 13, 45) },
{ date: "2023-03-01", time: "01:23", expected: new Date(2023, 2, 1, 1, 23) },
];
// Loop through the test cases and call the function with each pair of date and time
for (let testCase of testCases) {
// Call the function and store the result
const result = combineDateAndTime(testCase.date, testCase.time);
// Assert that the result is equal to the expected new date object
expect(result.getTime()).toEqual(testCase.expected.getTime());
}
});
it("addMinutes should add given time to date.", () => {
// Define some sample inputs and their expected outputs
const testCases = [
{
date: new Date(2023, 9, 12, 12, 0),
minutes: 15,
expected: new Date(2023, 9, 12, 12, 15),
},
{
date: new Date(2023, 0, 1, 0, 0),
minutes: -30,
expected: new Date(2022, 11, 31, 23, 30),
},
{
date: new Date(2022, 11, 31, 23, 59),
minutes: 1,
expected: new Date(2023, 0, 1, 0, 0),
},
{
date: new Date(2023, 1, 28, 13, 45),
minutes: 60,
expected: new Date(2023, 1, 28, 14, 45),
},
{
date: new Date(2023, 2, 1, 1, 23),
minutes: -1440,
expected: new Date(2023, 1, 28, 1, 23),
},
];
// Loop through the test cases and call the function with each pair of date and minutes
for (let testCase of testCases) {
// Call the function and store the result
const result = addMinutes(testCase.date, testCase.minutes);
// Assert that the result is equal to the expected modified date object
expect(result.getTime()).toEqual(testCase.expected.getTime());
}
});
it("shortTimeString should return time in short format.", () => {
// Define some sample dates and their expected short time strings
const testCases = [
{ date: new Date(2023, 9, 12, 12, 0), expected: "12:00 PM" },
{ date: new Date(2023, 0, 1, 0, 0), expected: "12:00 AM" },
{ date: new Date(2022, 11, 31, 23, 59), expected: "11:59 PM" },
{ date: new Date(2023, 1, 28, 13, 45), expected: "1:45 PM" },
{ date: new Date(2023, 2, 1, 1, 23), expected: "1:23 AM" },
];
// Loop through the test cases and call the function with each date
for (let testCase of testCases) {
// Call the function and store the result
const result = shortTimeString(testCase.date);
// Assert that the result is equal to the expected short time string
expect(result).toEqual(testCase.expected);
}
});
});

View file

@ -0,0 +1,101 @@
import store from "@/store";
import { storeActions } from "@/constants/store-actions";
import { partTypeStrings } from "@/constants/part-type-strings";
import baseMixin from "@/mixins/base-mixin.js";
export const promoPartNumberStrings = {
WIPER_DISCOUNT_PART_NUMBER: "WIPER DISCOUNT",
RAIN_DEFENSE_DISCOUNT_PART_NUMBER: "DISC RAIN DEFEN",
GLASS_DISCOUNT_PART_NUMBER: "DISCOUNT",
GLASS_CLEANER_DISCOUNT_PART_NUMBER: "DISC GLASS CLN",
};
export const pagesToStripPromoQueryStringFrom = ["quote", "payment-method"];
export function getAddableVapsFromAvailableLineItems(availableLineItems) {
const addableVaps = [];
addableVaps.push(...findLineItemsWithPartType(partTypeStrings.FRONT_WIPER, availableLineItems));
addableVaps.push(
...findLineItemsWithPartType(partTypeStrings.RAIN_DEFENSE, availableLineItems)
);
return addableVaps;
}
export function removeVapsPromosFromPromoArray(promoArray) {
if (!promoArray) {
return [];
}
const filteredPromoArray = promoArray.filter((promo) => {
return (
promo.partNumber != promoPartNumberStrings.WIPER_DISCOUNT_PART_NUMBER &&
promo.partNumber != promoPartNumberStrings.RAIN_DEFENSE_DISCOUNT_PART_NUMBER &&
promo.partNumber != promoPartNumberStrings.GLASS_CLEANER_DISCOUNT_PART_NUMBER
);
});
return filteredPromoArray;
}
export async function revalidatePromosAndValidateNewPromo(
newPromo,
pricedLineItems,
pageNameToLog
) {
const hasActivePromos = store.getters.order.lineItems.promos;
const hasHasInactivePromos = store.getters.order.lineItems.inactivePromos;
const hasNewPromo = !!newPromo;
const addableVaps = getAddableVapsFromAvailableLineItems(pricedLineItems);
let validatePromoResponse = null;
let revalidatePromoResponse = null;
if (hasActivePromos || hasHasInactivePromos) {
revalidatePromoResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.REVALIDATE_ORDER_PROMOS_AND_UPDATE_STORE,
null,
pageNameToLog,
false
);
}
if (hasNewPromo) {
validatePromoResponse = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.VALIDATE_ORDER_PROMO_AND_SAVE_SERVER_DATA,
{
promoCode: newPromo,
addableVaps: addableVaps,
},
pageNameToLog,
false
);
}
return { validatePromoResponse, revalidatePromoResponse };
}
export function getPromosThatMatchLineItemsOnOrder(promos, lineItemsOnOrder) {
const matchingPromos = [];
promos.forEach((promo) => {
let allIdsMatch = true;
promo.discountedLineItemIds.forEach((id) => {
if (lineItemsOnOrder.filter((x) => x.id === id).length === 0) {
allIdsMatch = false;
return;
}
});
if (allIdsMatch) {
matchingPromos.push(promo);
}
});
return matchingPromos;
}
export function shouldStripPromoQueryString(fmgPageQueryValue) {
return pagesToStripPromoQueryStringFrom.includes(fmgPageQueryValue);
}
// private methods
function findLineItemsWithPartType(typeToFind, itemsToSearch) {
const partTypeMatches = itemsToSearch?.filter(
(lineItem) => lineItem.partType.toUpperCase() === typeToFind.toUpperCase()
);
return partTypeMatches;
}

View file

@ -0,0 +1,250 @@
// Components
import addToCalendar from "@/layouts/add-to-calendar/add-to-calendar.vue";
import { calendarOptions } from "@/constants/calendar-options";
import { AppointmentTypeStrings, RouteCodeFlags } from "@/constants/schedule-constants";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import store from "@/store";
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
store.getters = {
order: {
schedule: {
date: "2019-01-01",
startTime: "09:00",
endTime: "10:00",
routeCode: "000",
},
lineItems: {
glassParts: [
{
partNumber: "ABC123",
},
],
supportingItems: [],
},
serviceLocation: {
address: "",
address2: null,
city: "",
state: "AZ",
appointmentType: "Inshop",
zipCode: "12345",
zipCodeCtu: "01234",
provider: {
providerNumber: "123",
address: {
streetAddress: "test1",
city: "test",
state: "AZ",
zipCode: "12345",
zipCodeCtu: "01234",
},
},
},
damage: {
isRepair: false,
},
referralNumber: "1234567",
},
payment: {
isInsurance: true,
},
lineItems: {
glassParts: [],
supportingItems: [],
},
};
});
afterEach(() => {
store.getters = {};
jest.restoreAllMocks();
jest.clearAllMocks();
});
describe("Add-to-calendar methods...", () => {
test("Add-to-calendar should trigger openModal method", () => {
//Arrange
const { wrapper } = setupMocks({});
//Act
wrapper.vm.openCalendarModal();
//Assert
expect(wrapper.vm.$refs.calendarModalQuestion.openModal).toBeCalled();
});
test("serviceType should return 'replacement and recalibration' when isRepair and funnelHasRecalibrationPart true.", () => {
//Arrange
store.getters.order.damage.isRepair = true;
store.getters.funnelHasRecalibrationPart = true;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.serviceType();
//Assert
expect(testValue).toEqual("replacement and recalibration");
});
test("serviceType should return 'replacement' when isRepair is true and funnelHasRecalibrationPart is false.", () => {
//Arrange
store.getters.order.damage.isRepair = true;
store.getters.funnelHasRecalibrationPart = false;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.serviceType();
//Assert
expect(testValue).toEqual("replacement");
});
test("serviceType should return 'repair' when isRepair is false.", () => {
//Arrange
store.getters.order.damage.isRepair = false;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.serviceType();
//Assert
expect(testValue).toEqual("repair");
});
test("getCalendarData should return expected model value.", () => {
//Arrange
const type = calendarOptions.OUTLOOKCOM;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getCalendarData(type);
//Assert
expect(testValue.name).toEqual(calendarOptions.OUTLOOKCOM);
});
test("getappointmentData should return expected model value for appointmentType mobile.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE;
store.getters.order.damage.isRepair = true;
store.getters.funnelHasRecalibrationPart = true;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getappointmentData();
//Assert
expect(testValue.Subject).toEqual("Safelite replacement and recalibration appointment");
});
test("getappointmentData should return expected model value for appointmentType DROP_OFF and OVERNIGHT_DROP_OFF.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.order.schedule.routeCode = RouteCodeFlags.OVERNIGHT_DROP_OFF;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getappointmentData();
//Assert
expect(testValue.Subject).toEqual("Drop off your vehicle by 5:30 PM");
});
test("getappointmentData should return expected model value for appointmentType DROP_OFF and ALL_DAY_DROP_OFF and isSameDayDropOff.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.order.schedule.routeCode = RouteCodeFlags.ALL_DAY_DROP_OFF;
store.getters.order.schedule.date = new Date().toISOString().split("T")[0];
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getappointmentData();
//Assert
expect(testValue.Subject).toEqual("Drop off your vehicle in the next 30 - 60 minutes");
});
test("getappointmentData should return expected model value for appointmentType DROP_OFF and ALL_DAY_DROP_OFF.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.order.schedule.routeCode = RouteCodeFlags.ALL_DAY_DROP_OFF;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getappointmentData();
//Assert
expect(testValue.Subject).toEqual("Drop off your vehicle between 9:00 AM - 11:00 AM");
});
test("getappointmentData should return expected model value for appointmentType DROP_OFF.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.order.damage.isRepair = false;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getappointmentData();
//Assert
expect(testValue.Subject).toEqual("Safelite repair appointment");
});
test("getappointmentData should return expected model value for appointmentType IN_SHOP.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.IN_SHOP;
store.getters.order.damage.isRepair = false;
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.getappointmentData();
//Assert
expect(testValue.Subject).toEqual("Safelite repair appointment");
});
test("isSameDayDropOff should return true for current schedule date.", () => {
//Arrange
store.getters.order.schedule.date = new Date().toISOString().split("T")[0];
const { wrapper } = setupMocks({});
//Act
const testValue = wrapper.vm.isSameDayDropOff();
//Assert
expect(testValue).toEqual(true);
});
test("setCalendarAppointment should call getAppointment method for iCAL and Outlook.", () => {
//Arrange
const newValue = { name: calendarOptions.OUTLOOK };
const { wrapper } = setupMocks({});
wrapper.vm.getAppointment = jest.fn();
//Act
wrapper.vm.setCalendarAppointment(newValue);
//Assert
expect(wrapper.vm.getAppointment).toHaveBeenCalled();
});
test("setCalendarAppointment should call window.open for Google, yahoo and Outlook.com.", () => {
//Arrange
const newValue = { name: calendarOptions.GOOGLE };
const { wrapper } = setupMocks({});
window.open = jest.fn();
//Act
wrapper.vm.setCalendarAppointment(newValue);
//Assert
expect(window.open).toHaveBeenCalledTimes(1);
});
});
function setupMocks({ customMountOptions }) {
const mountOptions = getMountOptions({
...customMountOptions,
});
const wrapper = shallowMount(addToCalendar, mountOptions);
//wrapper.vm.setCmsContent = jest.fn();
//wrapper.vm.$refs.datePicker.initializeComponent = jest.fn();
//wrapper.vm.$refs.locationAlerts.initializeComponent = jest.fn();
//wrapper.vm.$refs.funnelFooter.updateButtonText = jest.fn();
wrapper.vm.$refs.calendarModalQuestion.openModal = jest.fn();
return { wrapper };
}

View file

@ -0,0 +1,300 @@
<template>
<div class="calendar-section">
<div class="add-to-calendar">
<button v-on:click="openCalendarModal">
<span class="add-to-calendar-span">
<img src="@/assets/img/icons/add-to-calendar.svg" class="add-to-calendar-img" />
<span class="add-to-calendar-text">Add to calendar</span></span
>
</button>
</div>
<calendarModalQuestion
ref="calendarModalQuestion"
customComponentId="calendarModalQuestion"
cmsWidgetName="CalendarModalQuestion"
@calendar-modal-closed="calendarModalClosed"
:calendarOptionsData="calendarValues"
v-model="selectedCalendarOption">
</calendarModalQuestion>
</div>
</template>
<script>
import { calendarOptions } from "@/constants/calendar-options";
import { calendarStatus } from "@/constants/calendar-status";
import calendarModalQuestion from "@/layouts/add-to-calendar/calendar-modal-question/calendar-modal-question";
import {
getDateFormat,
calculateDuration,
combineDateAndTime,
shortTimeString,
addMinutes,
} from "@/helpers/date-helper";
import { getCalendarFile, download } from "@/helpers/add-to-calendar-helper";
import { AppointmentTypeStrings, RouteCodeFlags } from "@/constants/schedule-constants";
import store from "@/store";
export default {
name: "add-to-calendar",
components: {
calendarModalQuestion,
},
data() {
return {
appointment: this.getappointmentData(),
selectedCalendarOption: null,
};
},
computed: {
calendarValues: {
get: function () {
var obj = [];
const options = Object.values(calendarOptions);
for (var i = 0; i < options.length; i++) {
obj[i] = this.getCalendarData(options[i]);
}
return obj;
},
},
},
watch: {
selectedCalendarOption(newValue) {
if (newValue) {
this.setCalendarAppointment(newValue);
}
},
},
methods: {
setCalendarAppointment(calendarOption) {
if (
calendarOption.name.includes(calendarOptions.ICAL) ||
calendarOption.name.includes(calendarOptions.OUTLOOK)
) {
this.getAppointment();
} else {
window.open(calendarOption.url);
}
},
calendarModalClosed() {
// Clear the selectedOption if close the modal
this.selectedCalendarOption = null;
},
openCalendarModal() {
this.$refs["calendarModalQuestion"].openModal();
},
scheduleDate() {
return store.getters.order.schedule.date;
},
scheduleStartTime() {
return store.getters.order.schedule.startTime;
},
scheduleEndTime() {
return store.getters.order.schedule.endTime;
},
serviceLocationAddress() {
return store.getters.order.serviceLocation.address;
},
ServiceLocationAddress2() {
return store.getters.order.serviceLocation.address2;
},
serviceLocationCity() {
return store.getters.order.serviceLocation.city;
},
serviceLocationState() {
return store.getters.order.serviceLocation.state;
},
serviceLocationZipCode() {
return store.getters.order.serviceLocation.zipCode;
},
providerAddress() {
return store.getters.order.serviceLocation.provider.address.streetAddress;
},
providerCity() {
return store.getters.order.serviceLocation.provider.address.city;
},
providerState() {
return store.getters.order.serviceLocation.provider.address.state;
},
providerZipCode() {
return store.getters.order.serviceLocation.provider.address.zipCode;
},
appointmentType() {
return store.getters.order.serviceLocation.appointmentType;
},
uniqueId() {
return store.getters.order.referralNumber?.toString();
},
serviceType() {
const isRepair = store.getters.order.damage.isRepair;
const funnelHasRecalibrationPart = store.getters.funnelHasRecalibrationPart;
if (isRepair) {
if (funnelHasRecalibrationPart) {
return "replacement and recalibration";
} else {
return "replacement";
}
}
return "repair";
},
routeCode() {
return store.getters.order.schedule.routeCode;
},
isSameDayDropOff() {
const todaysDate = new Date().toISOString().split("T")[0];
return this.scheduleDate() === todaysDate;
},
getCalendarData(type) {
var URL = "";
const dateFormat = "yyyyMMddTHHmmss";
const outlookComTimeSpanFormat = "yyyy-MM-ddTHH:mm:ss";
if (type == calendarOptions.OUTLOOKCOM) {
URL = `https://outlook.office.com/calendar/deeplink/compose?path=/calendar/action/compose&rru=addevent&startdt=${encodeURIComponent(
getDateFormat(this.appointment.StartDate, outlookComTimeSpanFormat)
)}&enddt=${encodeURIComponent(
getDateFormat(this.appointment.EndDate, outlookComTimeSpanFormat)
)}&subject=${encodeURIComponent(
this.appointment.Subject
)}&body=${encodeURIComponent(this.appointment.Body)}&location=${encodeURIComponent(
this.appointment.Location
)}`;
}
if (type == calendarOptions.GOOGLE) {
URL = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(
this.appointment.Subject
)}&dates=${encodeURIComponent(
getDateFormat(this.appointment.StartDate, dateFormat)
)}/${encodeURIComponent(
getDateFormat(this.appointment.EndDate, dateFormat)
)}&details=${encodeURIComponent(
this.appointment.Body
)}&location=${encodeURIComponent(this.appointment.Location)}&sf=true&output=xml`;
}
if (type == calendarOptions.YAHOO) {
URL = `https://calendar.yahoo.com/?v=60&TITLE=${encodeURIComponent(
this.appointment.Subject
)}&DESC=${encodeURIComponent(this.appointment.Body)}&ST=${encodeURIComponent(
getDateFormat(this.appointment.StartDate, dateFormat)
)}&DUR=${this.appointment.Duration}&in_loc=${encodeURIComponent(
this.appointment.Location
)}`;
}
return {
url: URL,
name: type,
icon: require(`@/assets/img/icons/${type}.svg`),
};
},
getappointmentData() {
var subject = "";
var location = "";
var startDateTime = combineDateAndTime(this.scheduleDate(), this.scheduleStartTime());
var endDateTime = combineDateAndTime(this.scheduleDate(), this.scheduleEndTime());
var body = "";
var isHTML = false;
var duration = "";
if (this.appointmentType() === AppointmentTypeStrings.MOBILE) {
location = `${
this.ServiceLocationAddress2() ? this.ServiceLocationAddress2() + ", " : ""
}${this.serviceLocationAddress()}, ${this.serviceLocationCity()}, ${this.serviceLocationState()} ${this.serviceLocationZipCode()}`;
subject = `Safelite ${this.serviceType()} appointment`;
body =
"Your safety is our top priority, so we'll let you know who's coming to you and your tech will reach out to let you know when they're on the way.";
} else {
location = `${this.providerAddress()}, ${this.providerCity()}, ${this.providerState()} ${this.providerZipCode()}`;
if (this.appointmentType() === AppointmentTypeStrings.DROP_OFF) {
if (this.routeCode().includes(RouteCodeFlags.OVERNIGHT_DROP_OFF)) {
subject = "Drop off your vehicle by 5:30 PM";
body =
"Drop off your vehicle by 5:30 PM the night of your scheduled appointment. The shop will work with you on a convenient pick-up time.";
} else if (this.routeCode().includes(RouteCodeFlags.ALL_DAY_DROP_OFF)) {
if (this.isSameDayDropOff()) {
subject = "Drop off your vehicle in the next 30 - 60 minutes";
endDateTime = addMinutes(startDateTime, 120);
body =
"Drop your vehicle off in the next 30 - 60 minutes. We'll work your vehicle into our day's schedule. The shop will work with you on a convenient pick-up time.";
} else {
endDateTime = addMinutes(startDateTime, 120);
subject =
"Drop off your vehicle between " +
shortTimeString(startDateTime) +
" - " +
shortTimeString(endDateTime);
body = `Drop off your vehicle by ${shortTimeString(
endDateTime
)} and pick it up later in the day. When you arrive, park in an open space and follow the key drop instructions. We'll text you when your vehicle is ready.`;
}
} //normal time slot
else {
subject = `Safelite ${this.serviceType()} appointment`;
body = "Just a reminder that you're coming to us at " + location + ".";
}
} else {
subject = `Safelite ${this.serviceType()} appointment`;
body = "Just a reminder that you're coming to us at " + location + ".";
}
duration = calculateDuration(startDateTime, endDateTime);
}
return {
Subject: subject,
Location: location,
StartDate: startDateTime,
EndDate: endDateTime,
RefrrlSeqNum: this.uniqueId(),
Body: body,
IsHTML: isHTML,
Duration: duration,
};
},
getAppointment() {
let body = this.appointment.Body;
body = body.replace("|", "<").replace("~", ">");
const calFile = {
Location: this.appointment.Location,
Body: body,
IsHTML: this.appointment.IsHTML,
UniqueId: this.appointment.RefrrlSeqNum,
Subject: this.appointment.Subject,
StartDate: this.appointment.StartDate,
EndDate: this.appointment.EndDate,
Status: calendarStatus.BUSY,
};
var calBody = getCalendarFile(calFile);
download("SafeliteAppointment.ics", calBody);
},
},
};
</script>
<style scoped>
.add-to-calendar-text {
margin: 0 0 0 7px;
color: #1574a1;
vertical-align: middle;
font-weight: 500;
font-family: "Roboto";
}
.add-to-calendar button {
border: 0;
background-color: #fff;
}
.calendar-section {
display: table;
position: relative;
margin: 0 auto;
}
.calendar-section .add-to-calendar {
padding-top: 15px;
}
.calendar-section .add-to-calendar .add-to-calendar-span {
cursor: pointer;
}
.calendar-section .add-to-calendar .add-to-calendar-img {
display: inline;
width: 20px;
}
</style>

View file

@ -0,0 +1,79 @@
<template>
<baseInputButton
v-bind="$props"
buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2"
v-model="selectedValue">
<div
:aria-label="buttonLabel"
class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4">
<span class="m-0 position-relative" :class="textPosition">
<img :src="buttonLabel" />
<span class="calendar-text">{{ value }}</span>
</span>
</div>
</baseInputButton>
</template>
<script>
import baseInputButton from "@/digital-components/base-input-button/base-input-button";
import inputButtonWrapperMixin from "@/mixins/input-button-wrapper-mixin";
export default {
name: "calendarModalListButton",
mixins: [inputButtonWrapperMixin],
components: {
baseInputButton,
},
};
</script>
<style lang="scss" scoped>
.list-button {
outline: none;
input[type="radio"],
input[type="checkbox"] {
position: static; //override bootstrap
&:focus-visible + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content {
color: $black;
font-weight: 500;
background: $blue-100;
box-shadow: 0 0 0 1px $blue;
}
&:checked:focus + .list-button-content {
box-shadow: 0 0 0 2.5px $blue;
}
&:checked + .list-button-content p,
&:checked + .list-button-content span {
font-weight: 500;
}
&:checked + .list-button-content span:nth-child(2) {
font-weight: 400;
color: $gray-600;
}
}
}
.list-button-content {
color: $gray-600;
position: relative;
background: $white;
transition: all 150ms linear;
border-radius: $border-radius-lg;
border: 1px solid $gray-500;
width: 100%;
outline: none;
span.calendar-text {
margin-left: 5px;
padding: 2px 8px;
}
}
.position-relative {
position: relative;
}
</style>

View file

@ -0,0 +1,53 @@
import { shallowMount } from "@vue/test-utils";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
import calendarModalQuestion from "@/layouts/add-to-calendar/calendar-modal-question/calendar-modal-question.vue";
describe("calendar-modal-question.vue", () => {
test("When selected calendar option is changed, a correctly selectedCalendarOption should be emitted", async () => {
//Arrange
const url = "iCal url";
const name = "iCal";
const icon = "iCal.svg";
const expectedEmit = [
[
{
url: url,
name: name,
icon: icon,
},
],
];
const { wrapper } = setupMocks({
customMountOptions: {
propsData: {
calendarOptionsData: [
{
url: url,
name: name,
icon: icon,
},
],
},
},
});
wrapper.vm.$refs.calendarOptions.closeModal = jest.fn();
wrapper.vm.selectedCalendarOption = name;
//Act
wrapper.vm.setSelectedCalendarOption();
//Assert
expect(wrapper.emitted()["update:modelValue"]).toEqual(expectedEmit);
});
});
function setupMocks({ customMountOptions }) {
const mountOptions = getMountOptions({
...customMountOptions,
});
const wrapper = shallowMount(calendarModalQuestion, mountOptions);
return { wrapper };
}

View file

@ -0,0 +1,179 @@
<template>
<modal
:ref="modalName"
:fontSize="true"
:headerText="modalHeaderText"
:footerButtonText="footerCloseButtonText"
:onModalClosedCallback="onModalClosed"
@isModalOpened="setModalStatus"
@footer-button-event="setSelectedCalendarOption"
class="calendar-modal">
<template v-if="isModalOpened">
<buttonQuestion
ref="buttonQuestion"
buttonTypeString="calendarModalListButton"
:buttonTypeObject="calendarModalListButton"
:answers="getCalendarOptionData"
groupName="chooseCalendarType"
class="mt-1"
textPosition="text-center"
v-model="selectedCalendarOption"
isRequired
validationRules="calendar-option-required" />
</template>
</modal>
</template>
<script>
import modal from "@/digital-components/modal/modal";
import buttonQuestion from "@/digital-components/button-question/button-question";
import calendarModalListButton from "./calendar-modal-list-button/calendar-modal-list-button";
import { defineRule, useField } from "vee-validate";
import { errorMessages } from "@/constants/error-messages";
import { required } from "@/helpers/validation-rules";
import { deepClone } from "@/helpers/object-helper";
import { v4 as uuidv4 } from "uuid";
// Validation for the modal button
defineRule("calendar-option-required", required(errorMessages.OPTION_REQUIRED));
export default {
name: "calendar-modal-question",
components: {
modal,
buttonQuestion,
},
props: {
calendarOptionsData: Object,
modelValue: {
type: Object,
default: () => ({
url: null,
name: null,
icon: null,
}),
},
},
data() {
return {
isModalOpened: false,
selectedValue: null,
calendarModalListButton: calendarModalListButton,
};
},
setup(props) {
const uuid = uuidv4();
const componentId = !props.customComponentId
? `component-${uuid}`
: props.customComponentId;
const modelValue = deepClone(props).modelValue;
const initialValue = modelValue;
const fieldOptions = {
value: modelValue,
initialValue: initialValue,
};
const { errorMessage, handleChange, meta, validate, errors } = useField(
componentId,
props.validationRules,
fieldOptions
);
return {
componentId,
errorMessage,
handleChange,
validate,
meta,
errors,
};
},
watch: {
modelValue: {
handler(newValue) {
this.handleChange(newValue);
},
deep: true,
},
},
computed: {
modalName() {
return "calendarOptions";
},
modalHeaderText() {
return "Add to Calendar";
},
footerCloseButtonText() {
return "Add to Calendar";
},
modal() {
return this.$refs[this.modalName];
},
selectedCalendarOption: {
get: function () {
return this.modelValue?.name;
},
set: function (newValue) {
// Button Question only supports Number, or String data types so we must convert to the full object before emitting
this.selectedValue = this.getSelectedCalendarObject(newValue);
},
},
getCalendarOptionData() {
return this.calendarOptionsData.map((item) => {
return {
value: item.name,
buttonLabel: item.icon,
};
});
},
},
methods: {
openModal() {
this.modal.openModal();
},
setModalStatus(isOpened) {
this.isModalOpened = isOpened;
},
closeModal() {
this.modal.closeModal();
},
onModalClosed() {
this.$emit("calendar-modal-closed");
},
async setSelectedCalendarOption() {
this.$emit("update:modelValue", this.selectedValue);
this.closeModal();
},
getSelectedCalendarObject(calendarOptionId) {
const calendarOption = this.calendarOptionsData?.find(
(calendarOption) => calendarOption.name == calendarOptionId
);
if (calendarOption) {
return {
url: calendarOption.url,
name: calendarOption.name,
icon: calendarOption.icon,
};
}
return {
url: null,
name: null,
icon: null,
};
},
},
};
</script>
<style lang="scss" scoped>
.calendar-modal.modal.modal-component {
> .modal-dialog > .modal-content {
margin-top: 24px;
}
.modal-header {
padding-bottom: 0;
margin-bottom: 0 !important;
}
}
</style>

View file

@ -1,5 +1,7 @@
// Components
import confirmation from "@/layouts/confirmation/confirmation.vue";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
import { applicationConfig } from "@/constants/application-config.js";
// Supporting Files
import { shallowMount } from "@vue/test-utils";
@ -12,14 +14,14 @@ import router from "@/router";
jest.mock("@/helpers/layout-helper.js", () => ({
settleAllPromises: jest.fn(),
}));
var wordingText = "wording Text {custom:ADDRESS}";
beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
store.getters = {
order: {
schedule: {
date: "2019-01-01",
date: "2023-01-01",
startTime: "09:00",
endTime: "10:00",
routeCode: "000",
@ -33,9 +35,9 @@ beforeEach(() => {
supportingItems: [],
},
serviceLocation: {
address: "",
address2: null,
city: "",
address: "test",
address2: "123",
city: "test",
state: "AZ",
appointmentType: "Inshop",
zipCode: "12345",
@ -112,8 +114,188 @@ describe("confirmation.vue", () => {
expect(window.location.assign).toHaveBeenCalled();
});
});
describe("computed properties...", () => {
// test("ScheduleDateFormatted should return date in expected format.", () => {
// //Arrange
// const { wrapper } = setupMocks({});
// // Act
// const testValue = wrapper.vm.ScheduleDateFormatted;
// // Assert
// expect(testValue).toEqual("Sunday, January 1");
// });
test("ScheduleTimeFormatted should return mobile time in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE;
store.getters.order.schedule.startTime = "09:00";
store.getters.order.schedule.endTime = "11:00";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ScheduleTimeFormatted;
// Assert
expect(testValue).toEqual("Between 9 AM - 11 AM");
});
test("ScheduleTimeFormatted should return DROP_OFF time in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
store.getters.order.schedule.startTime = "09:00";
store.getters.order.schedule.endTime = "11:00";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ScheduleTimeFormatted;
// Assert
expect(testValue).toEqual("Drop off before 9:30 AM");
});
test("ScheduleTimeFormatted should return Inshop time in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.IN_SHOP;
store.getters.order.schedule.startTime = "09:00";
store.getters.order.schedule.endTime = "11:00";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ScheduleTimeFormatted;
// Assert
expect(testValue).toEqual("at 9:00 AM");
});
test("AppointmentWordingText should return mobile text in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.MOBILE;
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.AppointmentWordingText;
// Assert
expect(testValue).toEqual("wording Text 123, test,<br/> test, AZ 12345");
});
test("AppointmentWordingText should return DROP_OFF text in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.DROP_OFF;
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.AppointmentWordingText;
// Assert
expect(testValue).toEqual("wording Text test1,<br/> test, AZ 12345");
});
test("AppointmentWordingText should return IN_SHOP text in expected format.", () => {
//Arrange
store.getters.order.serviceLocation.appointmentType = AppointmentTypeStrings.IN_SHOP;
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.AppointmentWordingText;
// Assert
expect(testValue).toEqual("wording Text test1,<br/> test, AZ 12345");
});
test("ConfirmationEmailText should return text in expected format.", () => {
//Arrange
wordingText = "{custom:MY_ACCOUNT_URL}";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ConfirmationEmailText;
// Assert
expect(testValue).toEqual(applicationConfig.MY_ACCOUNT);
});
test("ScheduleDate should return date in expected format.", () => {
//Arrange
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ScheduleDate;
// Assert
expect(testValue).toEqual("2023-01-01");
});
test("ScheduleStartTime should return time in expected format.", () => {
//Arrange
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ScheduleStartTime;
// Assert
expect(testValue).toEqual("09:00");
});
test("ScheduleEndTime should return time in expected format.", () => {
//Arrange
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ScheduleEndTime;
// Assert
expect(testValue).toEqual("10:00");
});
test("InShopWordingText should return text in expected format.", () => {
//Arrange
wordingText = "InShopWordingText format";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.InShopWordingText;
// Assert
expect(testValue).toEqual(wordingText);
});
test("MobileWordingText should return text in expected format.", () => {
//Arrange
wordingText = "MobileWordingText format";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.MobileWordingText;
// Assert
expect(testValue).toEqual(wordingText);
});
test("DropOffWordingText should return text in expected format.", () => {
//Arrange
wordingText = "DropOffWordingText format";
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.DropOffWordingText;
// Assert
expect(testValue).toEqual(wordingText);
});
test("ServiceLocationFullAddress should return text in expected format.", () => {
//Arrange
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ServiceLocationFullAddress;
// Assert
expect(testValue).toEqual("123, test,<br/> test, AZ 12345");
});
test("ProviderFullAddress should return text in expected format.", () => {
//Arrange
const { wrapper } = setupMocks({});
// Act
const testValue = wrapper.vm.ProviderFullAddress;
// Assert
expect(testValue).toEqual("test1,<br/> test, AZ 12345");
});
});
const wordingText = "wording Text";
function setupMocks({ customMountOptions }) {
const mountOptions = getMountOptions({
...customMountOptions,

View file

@ -10,6 +10,10 @@
<img :src="ScheduleConfirmationImage" />
<span v-html="ScheduleConfirmationText"></span>
</div>
<div
class="appoinmenttext"
v-html="this.getAppointmentWordingText"></div>
<div class="main">
<div>
<vehicleBanner
@ -20,21 +24,8 @@
<p>{{ this.ScheduleDateFormatted }}</p>
<p>{{ this.ScheduleTimeFormatted }}</p>
</div>
<addToCalendar></addToCalendar>
<div class="calendar-section">
<div class="add-to-calendar">
<button>
<span class="add-to-calendar-span">
<img
src="~@/assets/img/icons/add-to-calendar.svg"
class="add-to-calendar-img" />
<span class="add-to-calendar-text"
>Add to calendar</span
></span
>
</button>
</div>
</div>
<div
class="appoinmenttext"
v-html="this.AppointmentWordingText"></div>
@ -58,6 +49,7 @@
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
import vehicleBanner from "@/fmg-components/vehicle-banner/vehicle-banner";
import navbar from "@/fmg-components/nav-bar/nav-bar";
import addToCalendar from "@/layouts/add-to-calendar/add-to-calendar";
//Supporting files
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
import { AppointmentTypeStrings } from "@/constants/schedule-constants";
@ -243,6 +235,7 @@ export default {
Form,
navbar,
vehicleBanner,
addToCalendar,
},
};
</script>

View file

@ -17,8 +17,8 @@
<cart
:damage="damageInfo"
:storeLineItems="storeLineItems"
:availableLineItems="cartItems"
:availableLineItems="availableLineItems"
:lineItems="lineItems"
servicePackageOptionsCmsName="ServicePackageTitle"
@cart-remove="cartRemove" />
@ -34,10 +34,12 @@
<add-vaps-modal-buttons
:cartItems="cartItems"
:wiperItems="availableWipers"
:rainDefenseItem="availableRainDefense"
vapsTilesCmsName="VapsProductTiles" />
:availableLineItems="availableLineItems"
vapsTilesCmsName="VapsProductTiles"
v-on="{ 'buttonEvent.openModal': openModal }" />
<button @click="openModal('RainDefenseModal')">OPEN MODAL</button>
<contentGroupModal ref="RainDefenseModal" cmsWidgetName="RainDefenseModal" />
<paymentMethodQuestion
v-if="!isPiaDisabled"
v-model="paymentMethodInternalModel" />
@ -64,6 +66,7 @@ import paymentMethodQuestion from "@/layouts/payment-method/payment-method-quest
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import cart from "@/fmg-components/cart/cart";
import addVapsModalButtons from "@/fmg-components/add-vaps-modal-buttons/add-vaps-modal-buttons";
import contentGroupModal from "@/fmg-components/content-group-modal/content-group-modal";
import { submitWorkOrder } from "@/helpers/heritage-integration/order-helper.js";
import alert from "@/ux-components/alert/alert";
@ -88,11 +91,14 @@ export default {
null,
"payment-method"
);
const rainDefensePromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_RAIN_DEFENSE,
null,
"payment-method"
);
console.log("rainDefensePromise: ", rainDefensePromise);
const supportingItemsPromise = baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.GET_SUPPORTING_ITEMS,
null,
@ -119,16 +125,17 @@ export default {
];
const resultMap = await settleAllPromises(promiseResultMap);
const clonedGlassParts = store.getters.order.lineItems.glassParts
? JSON.parse(JSON.stringify(store.getters.order.lineItems.glassParts))
: [];
const glassParts = (await store.getters.order.lineItems.glassParts) ?? [];
const availableLineItems = [
resultMap.rainDefense,
...resultMap.supportingItems,
...resultMap.wipers,
...clonedGlassParts,
...glassParts,
];
const pricingResults = await baseMixin.methods.dispatchStoreActionWithLogging(
const pricedAvailableLineItems = await baseMixin.methods.dispatchStoreActionWithLogging(
storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA,
{
availableLineItems: availableLineItems,
@ -136,30 +143,25 @@ export default {
"payment-method",
false
);
const storeLineItems = store.getters.lineItems;
const cartItems = [
...clonedGlassParts,
...storeLineItems.supportingItems,
...storeLineItems.vaps,
];
const lineItems = store.getters.lineItems;
const cartItems = [...glassParts, ...lineItems.supportingItems, ...lineItems.vaps]; // TOBEREMOVED BY AJC IN CSR-1384
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.cartItems = cartItems; // TOBEREMOVED BY AJC IN CSR-1384
vm.setCmsContent(resultMap.cmsContent);
vm.availableLineItems = pricingResults;
vm.availableWipers = resultMap.wipers;
vm.availableRainDefense = resultMap.rainDefense;
vm.cartItems = cartItems;
vm.availableLineItems = pricedAvailableLineItems;
vm.lineItems = lineItems;
});
},
data() {
return {
cartItems: [],
availableWipers: null,
availableRainDefense: null,
availableLineItems: null,
supportingItems: null,
pricedGlassParts: null,
cartItems: [], // TOBEREMOVED BY AJC IN CSR-1384
lineItems: [],
availableLineItems: [],
displayPaypalAlert: this.getPaypalAlert(),
paymentMethodInternalModel: this.getPaymentMethodFromStore(),
};
@ -217,6 +219,9 @@ export default {
packageReqs && serviceLocationReqs && isInsuranceSet && scheduleReqs && customerReqs
);
},
openModal(modalName) {
this.$refs[modalName].openModal();
},
getPaypalAlert() {
return store.getters.order.payment.piaErrorCode ? true : false;
},
@ -240,7 +245,7 @@ export default {
const matchedIndices = [];
this.cartItems.forEach((cartItem, i) => {
if (cartItem.partType === item.partType) {
console.log("adding this to matchedIndices: ", i);
// console.log("adding this to matchedIndices: ", i);
matchedIndices.push(i);
}
});
@ -253,18 +258,25 @@ export default {
this.$router.navigateWithoutSaving(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
this.dispatchStoreAction(storeActions.SAVE_PIA_ERROR_CODE, null, false);
this.dispatchStoreAction(this.storeActions.SAVE_WORK_ORDER_FLAG, true, false);
this.dispatchStoreAction(
this.storeActions.SAVE_PAYMENT_METHODS_CHOICE,
this.paymentMethod,
false
);
await submitWorkOrder({ pageNameToLog: "payment-method" });
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
switch (this.paymentMethod) {
case paymentMethods.CREDIT_CARD:
this.piaSetup("cc");
break;
case paymentMethods.PAYPAL:
this.piaSetup("pp");
break;
case paymentMethods.AFTERPAY:
this.piaSetup("ap");
break;
default:
this.dispatchStoreAction(storeActions.SAVE_PIA_ERROR_CODE, null, false);
this.dispatchStoreAction(this.storeActions.SAVE_WORK_ORDER_FLAG, true, false);
await submitWorkOrder({ pageNameToLog: "payment-method" });
this.$router.navigateWithoutSaving(
this.navigationScenarios.CLICKED_FORWARD,
this.$route
);
}
},
async ccdPayment() {
this.piaSetup("cc");
@ -278,16 +290,18 @@ export default {
async piaSetup(payNowType) {
this.$refs.loadingModal.showModal();
//leaving site, clear any save session promises that we will not be able to resolve when we return
// since we're leaving the site for pia, clear any save session promises that we will not be able to resolve when we return
await this.dispatchStoreAction(storeActions.RESET_SAVE_SESSION_PROMISE);
await this.dispatchStoreAction(storeActions.SAVE_PAY_NOW_TYPE, payNowType, false);
// make sure pia error codes are reset, and get a pia work order in delete status
// make sure pia error codes are reset
this.dispatchStoreAction(storeActions.SAVE_PIA_ERROR_CODE, null, false);
this.dispatchStoreAction(storeActions.SAVE_PIA_WORK_ORDER, true);
// if we don't have a work order in delete substatus, set the flag and submit
if (!store.getters.order.workOrderNumber) {
this.dispatchStoreAction(storeActions.SAVE_PIA_WORK_ORDER, true);
await submitWorkOrder({ pageNameToLog: "payment-method" });
this.dispatchStoreAction(storeActions.SAVE_PIA_WORK_ORDER, false);
}
this.$router.navigateWithoutSaving(
@ -298,12 +312,9 @@ export default {
},
computed: {
damageInfo() {
// console.log("this.$store.getters.damage ", this.$store.getters.damage)
return this.$store.getters.damage;
},
storeLineItems() {
// console.log("storeLineItems, aka lineItems from store: ", this.$store.getters.lineItems)
return this.$store.getters.lineItems;
},
customCtaCopy() {
switch (this.paymentMethod) {
case paymentMethods.CREDIT_CARD:
@ -322,7 +333,8 @@ export default {
const enablePIA =
this.getSettingValue(experimentSettings.SUBMIT_ORDER_ENABLE_PIA) === "true";
return !(piaExperience && enablePIA);
return false;
//return !(piaExperience && enablePIA);
},
paymentMethod() {
if (this.isPiaDisabled) {
@ -346,6 +358,7 @@ export default {
cart,
alert,
addVapsModalButtons,
contentGroupModal,
paymentMethodQuestion,
},
};

View file

@ -21,6 +21,12 @@ jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({
navigateToHeritageFunnel: jest.fn(),
}));
jest.mock("@/helpers/promotions-helper", () => ({
revalidatePromosAndValidateNewPromo: jest.fn(() => {
return { validatePromoResponse: null, revalidatePromoResponse: null };
}),
}));
jest.mock(
"@/store",
() => {
@ -43,11 +49,20 @@ jest.mock("@/mixins/base-mixin", () => ({
getTierOnePackagePrice() {
return mockTierOnePrice;
},
filterOutFees() {
filterOutFees(items) {
return null;
},
},
}));
const mockMixin = {
methods: {
filterOutFees: jest.fn().mockImplementation(() => {
return null;
}),
},
};
let mockTierOnePrice = 501;
const mockPriceOrderStoreAction = storeActions.PRICE_ORDER_ITEMS_AND_SAVE_SERVER_DATA;
@ -93,6 +108,16 @@ afterEach(() => {
describe("quote.vue", () => {
test("IsInsurance false should navigateWithSaving", async () => {
//Arrange
store.getters.payment = {
insuranceCoverage: {},
isInsurance: false,
};
store.getters.order = {
lineItems: [],
payment: {
parentAccountNumber: 167132,
},
};
const { wrapper } = setupMocks({
customMountOptions: {
router: {
@ -102,11 +127,6 @@ describe("quote.vue", () => {
},
});
store.getters.payment = {
insuranceCoverage: {},
isInsurance: false,
};
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
@ -124,20 +144,28 @@ describe("quote.vue", () => {
test("IsInsurance true should navigateToHeritageFunnel", async () => {
//Arrange
store.getters = {
payment: {
insuranceCoverage: {},
isInsurance: true,
},
order: {
lineItems: [],
payment: {
parentAccountNumber: null,
},
},
};
const { wrapper } = setupMocks({
customMountOptions: {
router: {
navigateWithSaving: jest.fn(),
},
route: { quote },
mixins: [mockMixin],
},
});
store.getters.payment = {
insuranceCoverage: {},
isInsurance: true,
};
wrapper.vm.dispatchStoreAction = jest.fn(() => {
return {
data: [],
@ -154,9 +182,9 @@ describe("quote.vue", () => {
});
test("should pass arePagePrerequisitesValid with a repair order", () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: [],
serviceLocation: {
zipCode: "12345",
zipCodeCtu: "value",
@ -172,6 +200,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
@ -179,7 +208,6 @@ describe("quote.vue", () => {
});
test("should pass arePagePrerequisitesValid with a replace order", () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
serviceLocation: {
@ -200,6 +228,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
@ -207,9 +236,9 @@ describe("quote.vue", () => {
});
test("should fail arePagePrerequisitesValid without glass parts or flagged as repair", () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: [],
serviceLocation: {
zipCode: "12345",
zipCodeCtu: "value",
@ -220,6 +249,7 @@ describe("quote.vue", () => {
referralNumber: "1234567",
},
};
const { wrapper } = setupMocks({});
let arePagePrerequisitesValid = wrapper.vm.arePagePrerequisitesValid();
@ -227,8 +257,6 @@ describe("quote.vue", () => {
});
test("should have non-null values for necessary data members after 'beforeRouteEnter'", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
@ -236,7 +264,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
//mock this to avoid needing to populate this.$route in an unrelated test
wrapper.vm.getDefaultIsInsuranceSelectedValue = jest.fn();
@ -257,8 +285,6 @@ describe("quote.vue", () => {
});
test("should default to insurance if query param 'isInsurance' is true", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
@ -272,6 +298,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
wrapper.vm.$route = { query: { isInsurance: "true" } };
//Act
@ -287,8 +314,6 @@ describe("quote.vue", () => {
});
test("should default to cash if query param 'isInsurance' is false", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
@ -302,6 +327,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
wrapper.vm.$route = { query: { isInsurance: "false" } };
//Act
@ -317,7 +343,6 @@ describe("quote.vue", () => {
});
test("should default to insurance if insurance selection is saved to store", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
@ -332,6 +357,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
@ -348,8 +374,6 @@ describe("quote.vue", () => {
});
test("should default to cash if cash selection is saved to store", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
@ -363,6 +387,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
@ -380,8 +405,6 @@ describe("quote.vue", () => {
test("should default to cash if total economy package price is under $500", async () => {
// Also needs no query parameter or previous selection in store to be present
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
@ -396,6 +419,7 @@ describe("quote.vue", () => {
},
};
mockTierOnePrice = 200;
const { wrapper } = setupMocks({});
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
@ -413,7 +437,6 @@ describe("quote.vue", () => {
test("should default to insurance if total economy package price is over $500", async () => {
// Also needs no query parameter or previous selection in store to be present
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
@ -429,6 +452,7 @@ describe("quote.vue", () => {
},
};
mockTierOnePrice = 505;
const { wrapper } = setupMocks({});
// Ensure that query param isn't overriding selection
wrapper.vm.$route = { query: null };
@ -445,7 +469,7 @@ describe("quote.vue", () => {
});
test("Should default to insurence if user Service zip is from certain States", async () => {
//Arrange
const { wrapper } = setupMocks({});
store.getters = {
order: {
lineItems: {
@ -459,6 +483,7 @@ describe("quote.vue", () => {
},
},
};
const { wrapper } = setupMocks({});
wrapper.vm.$route = { query: null };
//Act
await quote.beforeRouteEnter.call(

View file

@ -29,6 +29,7 @@
:availableLineItems="availableLineItems"
:isInsuranceSelected="isInsuranceSelected"
@vapsItemsSelected="vapsItemsSelectedAction"
:activePromos="allActivePromos"
v-on="{ 'buttonEvent.openModal': openModalAction }"
validationRules="option-required"
isRequired />
@ -79,6 +80,10 @@ import { Form, defineRule } from "vee-validate";
import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper";
import { applicationConfig } from "@/constants/application-config";
import { payWithInsuranceStates } from "@/constants/pay-with-insurance-states";
import { revalidatePromosAndValidateNewPromo } from "@/helpers/promotions-helper";
import { queryStrings } from "@/constants/query-strings";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { setTransitionHooks } from "vue";
defineRule("option-required", required(errorMessages.OPTION_REQUIRED));
export default {
@ -122,14 +127,12 @@ export default {
];
const resultMap = await settleAllPromises(promiseResultMap);
const clonedGlassParts = store.getters.order.lineItems.glassParts
? JSON.parse(JSON.stringify(store.getters.order.lineItems.glassParts))
: [];
const nullSafeGlassParts = store.getters.order.lineItems.glassParts ?? [];
const availableLineItems = [
resultMap.rainDefense,
...resultMap.supportingItems,
...resultMap.wipers,
...clonedGlassParts,
...nullSafeGlassParts,
];
const pricingResults = await baseMixin.methods.dispatchStoreActionWithLogging(
@ -146,13 +149,26 @@ export default {
resultMap.supportingItems,
false
);
// Promo logic
const promoCodeFromQueryString = getQuerystringParameter(queryStrings.PROMO);
const { validatePromoResponse, revalidatePromoResponse } =
await revalidatePromosAndValidateNewPromo(
promoCodeFromQueryString,
pricingResults,
"quote"
);
// End of promo logic
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.pricedGlassParts = clonedGlassParts;
vm.pricedGlassParts = nullSafeGlassParts;
vm.supportingItems = resultMap.supportingItems;
vm.availableLineItems = pricingResults;
vm.isInsuranceSelected = vm.getDefaultIsInsuranceSelectedValue(vm.availableLineItems);
vm.newValidatedPromos = validatePromoResponse?.data.orderPromos;
});
},
data() {
@ -162,8 +178,18 @@ export default {
availableLineItems: null,
supportingItems: null,
pricedGlassParts: null,
newValidatedPromos: null,
};
},
computed: {
allActivePromos() {
const allActivePromos = [];
if (this.newValidatedPromos) allActivePromos.push(...this.newValidatedPromos);
if (this.$store.getters.order.lineItems.promos)
allActivePromos.push(...this.$store.getters.order.lineItems.promos);
return allActivePromos;
},
},
methods: {
openModalAction(modalName) {
this.$refs[modalName].openModal();
@ -239,6 +265,7 @@ export default {
}
this.dispatchStoreAction(this.storeActions.SAVE_VAPS, this.selectedVaps, false);
this.dispatchStoreAction(this.storeActions.SAVE_PROMOS, this.newValidatedPromos, false);
const payment = this.$store.getters.payment;
if (payment.isInsurance) {

View file

@ -29,6 +29,10 @@ import {
containsLineItemWithPartType,
findLineItemsWithPartType,
} from "@/helpers/service-package-helper";
import {
getPromosThatMatchLineItemsOnOrder,
removeVapsPromosFromPromoArray,
} from "@/helpers/promotions-helper";
export default {
name: "servicePackageQuestion",
@ -40,6 +44,7 @@ export default {
isRequired: Boolean,
isInsuranceSelected: Boolean,
availableLineItems: null,
activePromos: null,
},
data() {
return {
@ -86,8 +91,11 @@ export default {
buttonLabel: this.getHeaderTextFromCms(answer.SubWidgetName),
buttonLabelSubCopy: this.getSubheaderTextFromCms(answer.SubWidgetName),
buttonBodyCopy: this.getBodyTextFromCms(answer.SubWidgetName),
buttonAuxillaryCopy: this.getPackagePriceString(answer.Name),
buttonAuxillaryCopy: this.getDiscountedPackagePriceString(answer.Name),
buttonFooterCopy: this.getFooterTextFromCms(answer.SubWidgetName),
additionalButtonData: {
strikeThroughPrice: this.getPackagePriceString(answer.Name),
},
}));
return modifiedAnswers;
},
@ -161,21 +169,33 @@ export default {
return this.processIfStatements(footerText, "custom", this.getCustomValueFromString);
},
getPackagePriceString(packageName) {
const formattedPriceFloat = parseFloat(this.getPackagePrice(packageName)).toFixed(2);
const formattedPriceFloat = parseFloat(
this.getPackagePrice(packageName, { discountedPrice: false })
).toFixed(2);
return "$" + formattedPriceFloat;
},
getDiscountedPackagePriceString(packageName) {
const formattedPriceFloat = parseFloat(
this.getPackagePrice(packageName, { discountedPrice: true })
).toFixed(2);
return (this.isInsuranceSelected ? "As little as $" : "$") + formattedPriceFloat;
},
getPackagePrice(packageName) {
getPackagePrice(packageName, { discountedPrice = false }) {
const lineItemsToPrice = [...this.nullSafeAvailableLineItems];
if (discountedPrice) {
lineItemsToPrice.push(...removeVapsPromosFromPromoArray(this.activePromos));
}
let priceFloat = this.isInsuranceSelected
? 0
: baseMixin.methods.getTierOnePackagePrice(
baseMixin.methods.filterOutFees(this.nullSafeAvailableLineItems)
baseMixin.methods.filterOutFees(lineItemsToPrice)
);
priceFloat += this.getVapsPrice(packageName);
priceFloat += this.getVapsPrice(packageName, discountedPrice);
return priceFloat;
},
getVapsPrice(packageName) {
getVapsPrice(packageName, applyPromoDiscounts = false) {
const vapsItems = this.getVapsLineItemsForSelectedPackage(packageName);
let price = 0;
@ -184,6 +204,16 @@ export default {
price += baseMixin.methods.getTotalLineItemPrice(item);
});
if (applyPromoDiscounts && this.activePromos) {
const relevantPromos = getPromosThatMatchLineItemsOnOrder(
this.activePromos,
vapsItems
);
relevantPromos.forEach((promo) => {
price += baseMixin.methods.getTotalLineItemPrice(promo);
});
}
return price;
},
selectDefaultPackage() {

View file

@ -28,6 +28,7 @@ import store from "@/store";
import analyticsMixin from "@/mixins/analytics-mixin";
import { experimentTriggers } from "../constants/experiments";
import { applicationConfig } from "../constants/application-config";
import { shouldStripPromoQueryString } from "@/helpers/promotions-helper";
const routes = [
{
@ -283,7 +284,7 @@ async function navigate(
queryStringsObject[queryStrings.ZIP_CODE] = zip;
}
if (promo) {
if (promo && !shouldStripPromoQueryString(currentRoute.query.fmgPage)) {
queryStringsObject[queryStrings.PROMO] = promo;
}

View file

@ -14,6 +14,7 @@ import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper
import { deepEqual } from "@/helpers/object-helper";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
import { getQuerystringParameter } from "@/helpers/querystring-helper";
import { deepClone } from "@/helpers/object-helper";
import { queryStrings } from "@/constants/query-strings";
import { partTypeStrings } from "@/constants/part-type-strings";
import {
@ -85,6 +86,7 @@ const getDefaultState = () => {
supportingItems: null,
vaps: null,
serverData: null,
promos: null,
},
payment: {
isInsurance: null,
@ -96,6 +98,7 @@ const getDefaultState = () => {
isPia: null,
piaType: null,
piaErrorCode: null,
inactivePromos: null,
},
schedule: {
date: null,
@ -196,6 +199,12 @@ export const mutations = {
updateLineItemsServerData(state, serverData) {
state.order.lineItems.serverData = serverData;
},
updateInactivePromos(state, inactivePromos) {
state.order.payment.inactivePromos = inactivePromos;
},
updatePromos(state, promos) {
state.order.lineItems.promos = promos;
},
updatePageData(state, pageData) {
state.applicationUser.pageData[pageData.page] = pageData.data;
},
@ -1239,10 +1248,10 @@ export const actions = {
return response;
},
getWipers(context, { pageNameToLog }) {
async getWipers(context, { pageNameToLog }) {
const carId = context.getters.vehicle.carId;
const serviceZipCode = context.getters.order.serviceLocation.zipCode;
return globalMethods
const response = await globalMethods
.callHttpClient({
method: endpoints.GetWipers.method,
endpoint: `${endpoints.GetWipers.url}/${carId}/${serviceZipCode}`,
@ -1253,15 +1262,20 @@ export const actions = {
// The wiper service sometimes returns 500s on legitimate carId/zipCode combination - return empty array instead of breaking flow
return [];
});
syncLineItemIds(response.data, context.getters.order.lineItems.vaps);
return response;
},
getRainDefense(context, { pageNameToLog }) {
return globalMethods.callHttpClient({
async getRainDefense(context, { pageNameToLog }) {
const response = await globalMethods.callHttpClient({
method: endpoints.GetRainDefense.method,
endpoint: endpoints.GetRainDefense.url,
logApiCall: true,
pageNameToLog: pageNameToLog,
});
syncLineItemIds([response.data], context.getters.order.lineItems.vaps);
return response;
},
getMobileFeePart(context, { pageNameToLog }) {
@ -1541,6 +1555,7 @@ export const actions = {
endpoint: endpoints.SaveSession.url,
payload: {
submitAfterSave: context.state.submitAfterSave,
userAgent: navigator.userAgent,
applicationUser: {
crmCustomerId: applicationUser.crmCustomerId,
experiments: applicationUser.experiments,
@ -1586,6 +1601,7 @@ export const actions = {
supportingItems: lineItems.supportingItems,
vaps: lineItems.vaps,
serverData: lineItems.serverData,
promos: lineItems.promos,
},
payment: {
InsuranceCoverage: {
@ -1593,6 +1609,7 @@ export const actions = {
},
isInsurance: order.payment.isInsurance,
parentAccountNumber: order.payment.parentAccountNumber,
inactivePromos: order.payment.inactivePromos,
},
serviceLocation: {
streetAddress: order.serviceLocation.address,
@ -2015,17 +2032,21 @@ export const actions = {
if (!deepEqual(supportingItems, context.state.order.lineItems.supportingItems)) {
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
}
addGuidToLineItemsIfNotAlreadyThere(supportingItems);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, supportingItems);
},
saveSupportingItemsSuppressingStateResetting(context, supportingItems) {
addGuidToLineItemsIfNotAlreadyThere(supportingItems);
context.commit(storeMutations.UPDATE_SUPPORTING_ITEMS, supportingItems);
},
saveVaps(context, vaps) {
addGuidToLineItemsIfNotAlreadyThere(vaps);
context.commit(storeMutations.UPDATE_VAPS, vaps);
},
savePromos(context, promos) {
context.commit(storeMutations.UPDATE_PROMOS, promos);
},
// Price order actions
async priceOrderItemsAndSaveServerData(
@ -2035,14 +2056,18 @@ export const actions = {
const zipCodeToUse = serviceZipCode
? serviceZipCode
: context.getters.order.serviceLocation.zipCode;
const ctuToUse = serviceZipCodeCtu
? serviceZipCodeCtu
: context.getters.order.serviceLocation.zipCodeCtu;
const flattenedLineItemsWithChildParts =
getFlattenedArrayOfLineItemsWithChildParts(availableLineItems);
const lineItemsWithOnlyPartNumbers = flattenedLineItemsWithChildParts.map((lineItem) => ({
partNumber: lineItem.partNumber,
}));
const availableLineItemsFormattedForRequest =
buildQueryStringParameterFromArrayOfComplexObjects(
lineItemsWithOnlyPartNumbers,
@ -2076,9 +2101,160 @@ export const actions = {
context.commit(storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA, response.data.serverData);
availableLineItems = addPricesToLineItems(availableLineItems, response.data.lineItems);
const pricedLineItems = addPricesToLineItems(availableLineItems, response.data.lineItems);
return availableLineItems;
return pricedLineItems;
},
// Tax order actions
async taxOrderItemsAndSaveServerData(
context,
{
payload: {
billToAccountNumber,
providerNumber,
appointmentType,
serviceLocationState,
serviceLocationZipCode,
pricedLineItems,
},
pageNameToLog,
}
) {
const flattenedLineItemsWithChildParts =
getFlattenedArrayOfLineItemsWithChildParts(pricedLineItems);
const lineItemsWithOnlyPriceInfo = flattenedLineItemsWithChildParts.map((lineItem) => ({
partNumber: lineItem.partNumber,
laborAmount: lineItem.laborAmount,
kitPrice: lineItem.kitPrice,
sellingPrice: lineItem.sellingPrice,
}));
const pricedLineItemsFormattedForRequest =
buildQueryStringParameterFromArrayOfComplexObjects(
lineItemsWithOnlyPriceInfo,
"lineItems"
);
let queryString =
`ParentAccountNumber=${applicationConfig.CASH_PARENT_ACCOUNT_NUMBER}` +
`&BillToAccountNumber=${billToAccountNumber}` +
`&ProviderNumber=${providerNumber}` +
`&AppointmentType=${appointmentType}` +
`&ServiceLocation.State=${serviceLocationState}` +
`&ServiceLocation.ZipCode=${serviceLocationZipCode}` +
`&${pricedLineItemsFormattedForRequest}`;
const lineItemServerData = context.getters.order.lineItems.serverData;
if (lineItemServerData) {
queryString += `&ServerData=${encodeURIComponent(lineItemServerData)}`;
}
const response = await globalMethods.callHttpClient({
method: endpoints.TaxOrderItems.method,
endpoint: `${endpoints.TaxOrderItems.url}?${queryString}`,
logApiCall: true,
pageNameToLog: pageNameToLog,
});
context.commit(storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA, response.data.serverData);
const taxedLineItems = addTaxesToPricedLineItems(
pricedLineItems,
response.data.taxedLineItems
);
return taxedLineItems;
},
async validateOrderPromoAndSaveServerData(
context,
{ payload: { promoCode, addableVaps }, pageNameToLog }
) {
const order = context.getters.order;
addGuidToLineItemsIfNotAlreadyThere(addableVaps);
const requestObject = {
promoCode: promoCode,
order: {
appointmentType: order.serviceLocation.appointmentType,
carId: order.vehicle.carId,
correlationId: order.referralCorrelationId,
eon: order.eon,
isRepair: order.damage.isRepair,
glassToReplace: renameGlassToReplaceAttributes(order.damage.glassToReplace),
lineItemsOnOrder: getArrayOfAllLineItems(order.lineItems),
addableVaps: addableVaps,
parentAccountNumber: order.payment.parentAccountNumber,
referralSequenceNumber: order.referralSequenceNumber,
serviceState: order.serviceLocation.state,
serverData: order.lineItems.serverData,
vehicleYear: "" + order.vehicle.year,
zipCodeOrProviderCtu:
order.serviceLocation.appointmentType === "Inshop"
? order.serviceLocation.provider.address.zipCodeCtu
: order.serviceLocation.zipCodeCtu,
},
};
const validateResponse = await globalMethods.callHttpClient({
method: endpoints.ValidatePromo.method,
endpoint: endpoints.ValidatePromo.url,
payload: requestObject,
logApiCall: true,
pageNameToLog: pageNameToLog,
});
context.commit(
storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA,
validateResponse.data.lineItemsServerData
);
return validateResponse;
},
async revalidateOrderPromosAndUpdateStore(context, { pageNameToLog }) {
const order = context.getters.order;
let requestObject = {
inactivePromos: order.payment.inactivePromos,
order: {
appointmentType: order.serviceLocation.appointmentType,
carId: order.vehicle.carId,
correlationId: order.referralCorrelationId,
eon: order.eon,
isRepair: order.damage.isRepair,
glassToReplace: renameGlassToReplaceAttributes(order.damage.glassToReplace),
lineItemsOnOrder: getArrayOfAllLineItems(order.lineItems),
parentAccountNumber: order.payment.parentAccountNumber,
referralSequenceNumber: order.referralSequenceNumber,
serviceState: order.serviceLocation.state,
serverData: order.lineItems.serverData,
vehicleYear: "" + order.vehicle.year,
zipCodeOrProviderCtu:
order.serviceLocation.appointmentType === "Inshop"
? order.serviceLocation.provider.address.zipCodeCtu
: order.serviceLocation.zipCodeCtu,
},
};
const revalidateResponse = await globalMethods.callHttpClient({
method: endpoints.RevalidatePromos.method,
endpoint: endpoints.RevalidatePromos.url,
payload: requestObject,
logApiCall: true,
pageNameToLog: pageNameToLog,
});
const revalidationErrorPromoCodes = revalidateResponse.data.errors.map((x) => x.promoCode);
context.commit(storeMutations.UPDATE_PROMOS, revalidateResponse.data.promoLineItems);
context.commit(storeMutations.UPDATE_INACTIVE_PROMOS, revalidationErrorPromoCodes);
context.commit(
storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA,
revalidateResponse.data.lineItemsServerData
);
return revalidateResponse;
},
// Misc order actions
@ -2145,7 +2321,7 @@ export const actions = {
if (!deepEqual(parts, context.state.order.lineItems.glassParts)) {
context.dispatch(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
}
addGuidToLineItemsIfNotAlreadyThere(parts);
context.commit(storeMutations.UPDATE_GLASS_PARTS, parts);
},
@ -2286,21 +2462,55 @@ function convertGlassPieceNamingFromApi(glassArray) {
}
function addPricesToLineItems(lineItems, pricingLineItems) {
lineItems.forEach((lineItem) => {
const pricedLineItems = deepClone(lineItems);
pricedLineItems.forEach((lineItem) => {
let lineItemIndex = pricingLineItems.findIndex(
(pricingLineItem) => pricingLineItem.partNumber === lineItem.partNumber
);
if (lineItem.childParts) {
addPricesToLineItems(lineItem.childParts, pricingLineItems);
}
let pricedLineItem = pricingLineItems.splice(lineItemIndex, 1)[0];
lineItem.laborAmount = pricedLineItem.laborAmount;
lineItem.sellingPrice = pricedLineItem.sellingPrice;
lineItem.kitPrice = pricedLineItem.kitPrice;
});
return pricedLineItems;
}
function addTaxesToPricedLineItems(lineItems, taxingLineItems) {
lineItems.forEach((lineItem) => {
let lineItemIndex = taxingLineItems.findIndex(
(taxingLineItem) => taxingLineItem.partNumber === lineItem.partNumber
);
if (lineItem.childParts) {
addTaxesToPricedLineItems(lineItem.childParts, taxingLineItems);
}
let taxedLineItem = taxingLineItems.splice(lineItemIndex, 1)[0];
lineItem.SalesTax = taxedLineItem.SalesTax;
});
return lineItems;
}
function getArrayOfAllLineItems(lineItems) {
let consolidatedLineItemsArray = [];
if (lineItems.glassParts != null)
consolidatedLineItemsArray = [...consolidatedLineItemsArray, ...lineItems.glassParts];
if (lineItems.supportingItems != null)
consolidatedLineItemsArray = [...consolidatedLineItemsArray, ...lineItems.supportingItems];
if (lineItems.vaps != null)
consolidatedLineItemsArray = [...consolidatedLineItemsArray, ...lineItems.vaps];
if (lineItems.promos != null)
consolidatedLineItemsArray = [...consolidatedLineItemsArray, ...lineItems.promos];
return consolidatedLineItemsArray;
}
function getFlattenedArrayOfLineItemsWithChildParts(lineItems) {
let flattenedArray = [];
lineItems?.forEach((lineItem) => {
@ -2335,6 +2545,37 @@ function convertGlassPieceToBackEndCompatibleFormat(glassPieces) {
});
}
function renameGlassToReplaceAttributes(glassToReplace) {
let newGlassToReplace = [];
if (glassToReplace) {
glassToReplace.forEach((item) => {
newGlassToReplace.push({ location: item.glassLocation, name: item.glassName });
});
}
return newGlassToReplace;
}
function addGuidToLineItemsIfNotAlreadyThere(lineItems) {
lineItems.forEach((lineItem) => {
if (!lineItem.id) {
lineItem.id = crypto.randomUUID();
}
});
}
function syncLineItemIds(lineItemsWithoutIds, lineItemsWithIds) {
if (!lineItemsWithIds || !lineItemsWithoutIds) {
return;
}
lineItemsWithoutIds.forEach((noId) => {
lineItemsWithIds.forEach((withId) => {
if (noId.partNumber === withId.partNumber && noId.partType === withId.partType) {
noId.id = withId.id;
}
});
});
}
function providersEqual(providerA, providerB) {
return (
providerA.providerNumber === providerB.providerNumber &&

View file

@ -9,6 +9,8 @@ import { AppointmentTypeStrings } from "@/constants/schedule-constants";
// Mock global method
globalMethods.callHttpClient = jest.fn();
global.crypto = { randomUUID: jest.fn() };
describe("Mutations", () => {
it("Updates vehicle year in state", () => {
// Arrange
@ -1378,6 +1380,8 @@ describe("Actions", () => {
state: state,
};
context.state.order.lineItems = [];
const commit = jest.fn();
const dispatch = jest.fn();
@ -1385,10 +1389,10 @@ describe("Actions", () => {
context.dispatch = dispatch;
// Act
actions.saveGlassParts(context, { glassParts: {} });
actions.saveGlassParts(context, []);
// Assert
expect(commit).toBeCalledWith(storeMutations.UPDATE_GLASS_PARTS, { glassParts: {} });
expect(commit).toBeCalledWith(storeMutations.UPDATE_GLASS_PARTS, []);
expect(dispatch).toBeCalledWith(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
});
@ -1417,7 +1421,7 @@ describe("Actions", () => {
state: {
order: {
lineItems: {
supportingItems: ["TestValue1", "TestValue2"],
supportingItems: [{ partNum: "TestValue1" }, { partNum: "TestValue2" }],
},
},
},
@ -1430,7 +1434,11 @@ describe("Actions", () => {
context.dispatch = dispatch;
// Act
actions.saveSupportingItems(context, ["TestValue3", "TestValue4", "TestValue5"]);
actions.saveSupportingItems(context, [
{ partNum: "TestValue3" },
{ partNum: "TestValue4" },
{ partNum: "TestValue5" },
]);
// Assert
expect(dispatch).toBeCalledWith(storeActions.RESET_SERVICE_LOCATION_STATE_AND_DEPENDENCIES);
@ -1442,7 +1450,7 @@ describe("Actions", () => {
state: {
order: {
lineItems: {
supportingItems: ["TestValue1", "TestValue2"],
supportingItems: [{ partNum: "TestValue1" }, { partNum: "TestValue2" }],
},
},
},
@ -1455,7 +1463,10 @@ describe("Actions", () => {
context.dispatch = dispatch;
// Act
actions.saveSupportingItems(context, ["TestValue1", "TestValue2"]);
actions.saveSupportingItems(context, [
{ partNum: "TestValue1" },
{ partNum: "TestValue2" },
]);
// Assert
expect(dispatch).not.toBeCalledWith(