Merge branch 'develop' into feature/digital/SSR-621

This commit is contained in:
Katie Kroell 2023-09-15 11:13:11 -04:00
commit 453ddc4ca3
23 changed files with 1925 additions and 167 deletions

View file

@ -43,6 +43,10 @@ const endpoints = Object.freeze({
url: '/price/api/v1/price/order-items',
method: 'GET'
},
GetProviders: {
url: '/location/api/v1/location/providers',
method: 'GET'
},
GetCapabilityQuestions: {
url: '/parts/api/v1/parts/capability-questions',
method: 'GET'
@ -63,6 +67,10 @@ const endpoints = Object.freeze({
url: '/parts/api/v1/parts/supporting-items',
method: 'POST'
},
GetMobileFeePart: {
url: '/parts/api/v1/parts/mobile-fee',
method: 'GET'
},
GetServiceabilityDetails: {
url: '/location/api/v1/location/serviceability-details',
method: 'GET'

View file

@ -0,0 +1,13 @@
const AppointmentTypeStrings = {
IN_SHOP: 'Inshop',
MOBILE: 'Mobile',
DROP_OFF: 'Dropoff'
};
const PREMIUM_FEE_PART_TYPE = 'EARLY BIRD';
const PREMIUM_TIME_SLOT_ID_FLAG = '-PREMIUM';
const RouteCodeFlags = {
ALL_DAY_DROP_OFF: 'ALL DAY DROP OFF',
OVERNIGHT_DROP_OFF: 'OVERNIGHT DROP OFF'
};
export { AppointmentTypeStrings, PREMIUM_TIME_SLOT_ID_FLAG, PREMIUM_FEE_PART_TYPE, RouteCodeFlags };

View file

@ -47,6 +47,7 @@ import { Modal } from 'bootstrap';
import { useForm } from 'vee-validate';
export default {
// eslint-disable-next-line vue/multi-word-component-names
name: 'modal',
components: {
modalButtonMain
@ -118,11 +119,19 @@ export default {
<style lang="scss" scoped>
.modal {
overflow: hidden;
top: auto;
bottom: 0;
h5,
strong,
:deep(h5) {
color: $black;
}
:deep(p) {
strong {
color: $black;
}
}
.subheader-text {
color: $black;
}
@ -141,6 +150,13 @@ export default {
color: $black;
}
}
.modal-footer {
position: sticky;
width: 100%;
bottom: 0;
z-index: 5;
background-color: $gray-100;
}
&.modal-component {
.modal-dialog {
max-width: 576px;

View file

@ -17,6 +17,16 @@ const mockProps = {
justifyText: 'mockJustifyText'
};
// Mock fetchCmsContentForPage
jest.mock('@/helpers/cms-content-helper', () => ({
doesCopyContainRouterLink: jest.fn(),
doesCopyContainTextLink: jest.fn(),
splitCopyOnCMSPlaceHolder: jest.fn(() => mockCmsContent.Text.split(/{(.*?)}/g)),
getRouterLinkRouteFromCopy: jest.fn(),
getRouterLinkDisplayTextFromCopy: jest.fn(),
getExternalLink: jest.fn()
}));
describe('modal.vue', () => {
it("Should display 'Text' when 'Text' is defined in the CMS", async () => {
// Act

View file

@ -1,23 +1,91 @@
<template>
<div
class="text-block w-100"
:class="[justifyText, typeStyle, fontWeight]"
v-html="this.TextBlockCopy"></div>
:class="[justifyText, typeStyle, fontWeight, marginTopClass]">
<span
v-for="copy in splitCopyOnCMSPlaceHolder(textBlockCopy)"
:key="copy">
<span v-if="doesCopyContainRouterLink(copy)">
<textLink
linkType="text"
:text="getRouterLinkDisplayTextFromCopy(copy)"
href="javascript:void(0)"
useLoadingModal
@click-event="navigateWithScenario(getRouterLinkRouteFromCopy(copy))" />
</span>
<span v-else-if="doesCopyContainTextLink(copy)">
<textLink
linkType="text"
:text="getRouterLinkDisplayTextFromCopy(copy)"
:href="getExternalLink(copy)"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
@click-event="$emit('textLinkClicked', getRouterLinkRouteFromCopy(copy))" />
</span>
<span
v-else
v-html="copy"></span>
</span>
</div>
</template>
<script>
// Components
import textLink from '@/ux-components/text-link/text-link.vue';
// Supporting files
import {
doesCopyContainRouterLink,
doesCopyContainTextLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
getExternalLink
} from '@/helpers/cms-content-helper';
import applicationConfig from '@/constants/application-config';
export default {
name: 'textBlock',
name: 'text-block',
components: {
textLink
},
props: {
customText: String, // used to allow the insert of token values into textblock
justifyText: String, // left, right, center
typeStyle: String, // h1-h6, body, small, label, caption
// (see Figma or Confluence documentation)
fontWeight: String, // bold=500, default is 400
cmsWidgetName: String
cmsWidgetName: String,
marginTopSizeOverride: Number // override mt-2 with a bootstrap size from 0-5 or auto
},
computed: {
TextBlockCopy() {
pageQueryString() {
return applicationConfig.PAGE_QUERYSTRING;
},
textBlockCopy() {
if (this.customText) {
return this.customText;
}
return this.getCmsContent(this.cmsWidgetName, 'Text');
},
marginTopClass() {
if (this.marginTopSizeOverride === 'auto') {
return 'mt-auto';
}
if (this.marginTopSizeOverride >= 0 && this.marginTopSizeOverride <= 5) {
return `mt-${this.marginTopSizeOverride}`;
}
return 'mt-2';
}
},
methods: {
doesCopyContainRouterLink,
doesCopyContainTextLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
getExternalLink,
navigateWithScenario(scenarioName) {
this.$router.navigateWithoutSaving(scenarioName, this.$route);
}
}
};
@ -30,15 +98,16 @@ export default {
justify-content: flex-start;
}
&.right {
display: flex;
justify-content: flex-end;
text-align: right;
}
&.center {
display: flex;
justify-content: center;
text-align: center;
}
&.bold {
font-weight: 500;
}
&.dark {
color: $black;
}
}
</style>

View file

@ -12,9 +12,11 @@ function processWidgetItemForReplacement(widgetModel, key) {
// If we have a string, and it needs to be replaced.
if (typeof widgetModel[key] === 'string') {
if (widgetModel[key].includes('{if:')) {
widgetModel[key] = processIfStatements(widgetModel[key],
widgetModel[key] = processIfStatements(
widgetModel[key],
dynamicStrings.GLOBAL_STATE,
getStoreValueFromString);
getStoreValueFromString
);
}
if (widgetModel[key].includes(dynamicStrings.MODAL_LINK)) {
@ -158,14 +160,16 @@ export function fetchCmsContentForPage(issPage) {
// Else get the client override page.
const pageName = `${issPage}_${clientName.toLowerCase().replace(/ /g, '')}`;
return store.getPageData(pageName).then((clientResponse) =>
// Process the client override if it exists.
processPageData(baseResponse, clientResponse),
(error) => {
console.error(error);
// Process the just the base if no client override exists.
return processPageData(baseResponse, null);
});
return store.getPageData(pageName).then(
(clientResponse) =>
// Process the client override if it exists.
processPageData(baseResponse, clientResponse),
(error) => {
console.error(error);
// Process the just the base if no client override exists.
return processPageData(baseResponse, null);
}
);
})
);
}
@ -179,8 +183,10 @@ function mapStringToModal(str) {
let linkToReplace = str.substring(startIndex, str.length);
linkToReplace = linkToReplace.substring(0, linkToReplace.indexOf('}') + 1);
const params = linkToReplace.substring(dynamicStrings.MODAL_LINK.length + 2,
linkToReplace.length - 1);
const params = linkToReplace.substring(
dynamicStrings.MODAL_LINK.length + 2,
linkToReplace.length - 1
);
const splitParams = params.split(',');
const bodyText = `<a href="#" modalTarget="${splitParams[0]}" class="modal-text" aria-label="Modal window">${splitParams[1]}</a>`;
@ -202,8 +208,10 @@ function mapStringToLink(str) {
let linkToReplace = str.substring(startIndex, str.length);
linkToReplace = linkToReplace.substring(0, linkToReplace.indexOf('}') + 1);
const params = linkToReplace.substring(dynamicStrings.EXTERNAL_LINK.length + 2,
linkToReplace.length - 1);
const params = linkToReplace.substring(
dynamicStrings.EXTERNAL_LINK.length + 2,
linkToReplace.length - 1
);
const splitParams = params.split(',');
const bodyText = `<a href="${splitParams[0]}" class="external-text" target="_blank">${splitParams[1]}</a>`;
@ -291,13 +299,17 @@ export function processIfStatements(str, ifConditionKeyword, replacePlaceholderC
}
const ifStatementRegexExpression = getIfStatementRegexExpression();
const ifStatementRegexMatches = [...str.matchAll(ifStatementRegexExpression)];
const completeIfStatementArray = getAndFlagFirstNonNestedIfStatementWithKeyword(ifStatementRegexMatches,
ifConditionKeyword);
const completeIfStatementArray = getAndFlagFirstNonNestedIfStatementWithKeyword(
ifStatementRegexMatches,
ifConditionKeyword
);
executeIfStatementAndSetProcessedStrings(completeIfStatementArray, replacePlaceholderCallback);
const reconstructedPostProcessedString = joinProcessedRegexArray(ifStatementRegexMatches);
return processIfStatements(reconstructedPostProcessedString,
return processIfStatements(
reconstructedPostProcessedString,
ifConditionKeyword,
replacePlaceholderCallback);
replacePlaceholderCallback
);
}
/**
@ -419,26 +431,28 @@ function getIfStatementRegexExpression() {
// {if:...} or {else} or {end}
const anyLogicOperatorNonCapture = '(?:{(?:end|else|if:.*?)})';
// NOTE: ?<variableName> syntax stores the captured match like so: match.groups.variableName
const matchStartOfString
= '(?<processedString>^.+?)' // Match and Capture all characters (lazy), cannot be empty
const matchStartOfString =
'(?<processedString>^.+?)' // Match and Capture all characters (lazy), cannot be empty
+ '(?=(?:{if))'; // Looks ahead but does not capture {if
const matchIfOperator
= '(?<isIfStatement>{if:)' // Match & Capture {if:
const matchIfOperator =
'(?<isIfStatement>{if:)' // Match & Capture {if:
+ '(?<ifConditionType>.*?):' // Match all chars up to and including next ':' - Capture all chars up to ':'
+ '(?<ifCondition>.*?)}' // Match all chars up to and including next '}' - Capture all chars up to '}'
+ '(?<ifTrailingString>.*?)' // Match and Capture all characters (lazy), can be empty
+ `(?=${anyLogicOperatorNonCapture})`; // Looks ahead but does not capture the next logic operator
const matchElseOperator
= '(?<isElseStatement>{else})' // Match & Capture {else}
const matchElseOperator =
'(?<isElseStatement>{else})' // Match & Capture {else}
+ '(?<elseTrailingString>.*?)' // Match & Capture all characters (lazy), can be empty
+ `(?=${anyLogicOperatorNonCapture})`; // Looks ahead but does not capture the next logic operator
const matchEndOperator
= '(?<isEndStatement>{end})' // Match & Capture {end}
const matchEndOperator =
'(?<isEndStatement>{end})' // Match & Capture {end}
+ '(?<endTrailingString>.*?)' // Match & Capture all characters (lazy), can be empty
+ `(?=${anyLogicOperatorNonCapture}|$)`; // Looks ahead but does not capture the next logic operator
// Combine all matching patterns, separated by 'or' pipes
return new RegExp(`${matchStartOfString}|${matchIfOperator}|${matchElseOperator}|${matchEndOperator}`,
'g');
return new RegExp(
`${matchStartOfString}|${matchIfOperator}|${matchElseOperator}|${matchEndOperator}`,
'g'
);
}
/// ///////////////////////////////////////
@ -501,6 +515,16 @@ export function splitCopyOnCMSPlaceHolder(copy) {
return copy.split(/{(.*?)}/g);
}
export function getExternalLink(copy) {
const url = copy.split(':')[1].split(',')[0];
if (url.includes('https-')) {
const prefixAdded = url.replace('https-', 'https://');
return prefixAdded;
}
return '#!';
}
/**
* Returns string2 of input following this pattern: {string1:string2,string3}
* @param copy

View file

@ -0,0 +1,80 @@
// For nested objects, spread operator only creates new references to the top level fields,
// the remaining nested fields actually reference the original object which can introduce problems.
// The purpose of this method is to deep clone the data in an object recursively, this is useful
// for cloning modelValues to internal models when regular two-way binding is not an option.
// See: mobile-location-modal-questions.vue
// Creates a deep clone of an object. Clones primitives, arrays and objects, excluding class instances.
// https://www.30secondsofcode.org/js/s/deep-clone
export function deepClone(object) {
if (object === null) {
return null;
}
const clone = { ...object };
// eslint-disable-next-line no-return-assign
Object.keys(clone).forEach((key) =>
(clone[key] = typeof object[key] === 'object' ? deepClone(object[key]) : object[key]));
if (Array.isArray(object)) {
clone.length = object.length;
return Array.from(clone);
}
return clone;
}
// The purpose of this method is to check for array or object equality recursively to determine if two complex objects are equal.
// This is only a comparison of data, not functions.
export function deepEqual(obj1, obj2) {
if (typeof obj1 !== typeof obj2) {
return false;
}
if (obj1 === null || obj2 === null) {
return obj1 === obj2;
}
if (Array.isArray(obj1) && Array.isArray(obj2)) {
if (obj1.length !== obj2.length) {
return false;
}
const sorted1 = obj1.slice().sort();
const sorted2 = obj2.slice().sort();
for (let i = 0; i < sorted1.length; i++) {
if (!deepEqual(sorted1[i], sorted2[i])) {
return false;
}
}
return true;
}
if (typeof obj1 === 'object' && typeof obj2 === 'object') {
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) {
return false;
}
const sortedKeys1 = keys1.sort();
const sortedKeys2 = keys2.sort();
for (let i = 0; i < sortedKeys1.length; i++) {
const key1 = sortedKeys1[i];
const key2 = sortedKeys2[i];
if (key1 !== key2 || !deepEqual(obj1[key1], obj2[key2])) {
return false;
}
}
return true;
}
return obj1 === obj2;
}

View file

@ -1,16 +1,5 @@
import { useMainStore } from '@/store';
export async function getServiceabilityDetails(serviceZipCode, lineItems) {
const serviceabilityDetails = await useMainStore().getServiceabilityDetails(
{
serviceZipCode,
lineItems
},
false
);
return Promise.resolve(serviceabilityDetails);
}
export async function getZipCodeData(zipCode) {
const serviceZipValidationResponse = await useMainStore().validateZip({ zip: zipCode });
@ -22,3 +11,57 @@ export async function getZipCodeData(zipCode) {
zipCodeCtu: serviceZipValidationResponse.data.zipCodeCtu
};
}
export async function getPricedMobileFeePart(serviceZipCode) {
if (!serviceZipCode) {
return Promise.resolve(null);
}
const zipCodeData = await getZipCodeData(serviceZipCode);
// Get the Mobile Fee Part
const mobileFeePart = await useMainStore().getMobileFeePart();
// Get the Mobile Fee Part Price
const pricingResults = await useMainStore()
.priceOrderItemsAndSaveServerData([mobileFeePart.data], serviceZipCode, zipCodeData.zipCodeCtu);
return Promise.resolve(pricingResults[0]);
}
export async function getServiceabilityDetails(serviceZipCode, lineItems) {
const serviceabilityDetails = await useMainStore().getServiceabilityDetails(
{
serviceZipCode,
lineItems
},
false
);
return Promise.resolve(serviceabilityDetails);
}
export async function getAvailabilityRating(
startDate,
endDate,
shopAppointmentType,
providerNumber
) {
// For a given shop provider number and date range, get the appointment time slots available
const shopTimeSlots = await useMainStore().getShopTimeSlots(
{
providerNumber,
startDate,
endDate,
shopAppointmentType
},
false
);
const numberOfDaysToEvaluate = 2;
const isGoodAvailability =
shopTimeSlots.data.days.filter((x) => x.timeSlots.length > 0).length
>= numberOfDaysToEvaluate;
const shopStatus = isGoodAvailability ? 'high' : 'low';
return Promise.resolve(shopStatus);
}

View file

@ -2,7 +2,7 @@
<div>
<footer
id="infoBox"
class="footer container-fluid g-5 px-0">
class="footer container-fluid g-5 my-5 px-0">
<div class="row d-flex flex-row-reverse align-items-center vw-100 mx-0">
<div
id="stacked"

View file

@ -20,7 +20,7 @@ import settleAllPromises from '@/helpers/layout-helper';
import { useMainStore } from '@/store';
export default {
name: 'bailout-confirmation',
name: 'contact-confirmation',
components: {
siteHeader,
siteSubHeader
@ -47,9 +47,9 @@ export default {
return { mainStore };
},
data() {
const bailoutConfirmationData = useMainStore().pageData(issPageValues.BAILOUT_CONFIRMATION);
const contactConfirmationData = useMainStore().pageData(issPageValues.CONTACT_CONFIRMATION);
return {
bailoutConfirmationModel: bailoutConfirmationData
contactConfirmationModel: contactConfirmationData
};
},
computed: {},

View file

@ -139,6 +139,8 @@ export default {
async beforeRouteEnter(to, from, next) {
// Call APIs
const cmsContentPromise = fetchCmsContentForPage(to.query.issPage);
const wipersPromise = useMainStore().getWipers();
const rainDefensePromise = useMainStore().getRainDefense();
const supportingItemsPromise = await useMainStore().getSupportingItems();
// Settle promises and get results
@ -147,6 +149,14 @@ export default {
resultKey: 'cmsContent',
promise: cmsContentPromise
},
{
resultKey: 'wipers',
promise: wipersPromise
},
{
resultKey: 'rainDefense',
promise: rainDefensePromise
},
{
resultKey: 'supportingItems',
promise: supportingItemsPromise
@ -158,7 +168,9 @@ export default {
? JSON.parse(JSON.stringify(useMainStore().order.lineItems.glassParts))
: [];
const availableLineItems = [
resultMap.rainDefense,
...resultMap.supportingItems,
...resultMap.wipers,
...clonedGlassParts
];
@ -167,6 +179,7 @@ export default {
// Call the "next" function to complete the transition to this page.
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.setSupportingItems(resultMap.supportingItems);
// eslint-disable-next-line no-param-reassign
vm.availableLineItems = pricingResults;
});
@ -188,7 +201,8 @@ export default {
],
rules: {
selectionRequired: globalRules.OPTION_REQUIRED
}
},
supportingItems: null
};
},
computed: {
@ -341,6 +355,7 @@ export default {
},
async navigateForward() {
if (this.unverified || this.verifiedDeductible) {
this.mainStore.saveSupportingItems(this.supportingItems);
this.$router.navigate(
navigationScenarios.CLICKED_FORWARD,
this.$route,
@ -349,6 +364,7 @@ export default {
);
} else if (this.verifiedITAC || this.verifiedNoComp) {
if (this.selectedProvider === 'Safelite') {
this.mainStore.saveSupportingItems(this.supportingItems);
this.$router.navigate(
navigationScenarios.CLICKED_FORWARD_WITH_SAFELITE,
this.$route,
@ -437,6 +453,9 @@ export default {
},
getITACCostSavings(vehicleDeductible, totalServicePrice) {
return vehicleDeductible - totalServicePrice;
},
setSupportingItems(newSupportingItems) {
this.supportingItems = newSupportingItems;
}
}
};

View file

@ -0,0 +1,108 @@
<template>
<transition
name="fade"
mode="out-in">
<div
v-if="isDisplayed"
class="appointment-type-question"
aria-live="polite">
<buttonQuestion
ref="buttonQuestion"
v-model="selectedValues"
customButtonQuestionId="appointmentTypeQuestion"
:questionText="questionText"
:answers="answersToDisplay"
:groupName="groupName"
buttonTypeString="listCard"
:suppressError="suppressError"
:validationRules="validationRules"
isRequired />
</div>
</transition>
</template>
<script>
import { useMainStore } from '@/store';
import { AppointmentTypeStrings } from '@/constants/schedule-constants.js';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
export default {
name: 'appointment-type-question',
components: {
buttonQuestion
},
props: {
modelValue: String,
groupName: String,
isAvailable: Boolean,
isDisplayed: Boolean,
suppressError: Boolean,
validationRules: String,
cmsWidgetName: String,
isServiceableMobile: Boolean,
isServiceableInshop: Boolean
},
emits: ['update:modelValue'],
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
answersFromCms() {
return this.getCmsContent(this.cmsWidgetName, 'Answers');
},
answersToDisplay() {
const shouldShowMobile = this.isServiceableMobile;
const shouldShowInshop = this.isServiceableInshop;
const shouldShowDropoff = this.isServiceableInshop && !useMainStore().damage.isRepair;
return this.answersFromCms
? this.answersFromCms.filter((answer) => (
(answer.Name === AppointmentTypeStrings.IN_SHOP && shouldShowInshop)
|| (answer.Name === AppointmentTypeStrings.MOBILE && shouldShowMobile)
|| (answer.Name === AppointmentTypeStrings.DROP_OFF && shouldShowDropoff)
))
: [];
},
selectedValues: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
}
},
isMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
}
},
watch: {
answersToDisplay: {
handler(newValue) {
// If there is only one option to display and that option is 'Mobile' then select it
if (
newValue.length === 1
&& newValue.findIndex((answer) => answer.Name === 'Mobile') !== -1
) {
this.selectedValues = 'Mobile';
}
},
immediate: true
},
isMobileOnly: {
handler(newValue) {
if (newValue) {
this.selectedValues = 'Mobile';
}
}
}
}
};
</script>
<style lang="scss" scoped>
:deep(.button-question) {
.list-card img {
height: auto;
width: 3.417rem;
}
}
</style>

View file

@ -0,0 +1,322 @@
<template>
<transition
name="fade"
mode="out-in">
<div class="mobile-location-questions">
<div
:id="componentId"
class="text-center">
<label
for="mobileLocationLinkPromptId"
:aria-label="mobileLocationLinkPromptText"
class="form-label fw-bold w-100 ps-4 pe-4 pt-4 text-black"
v-html="mobileLocationLinkPromptText"></label>
<div class="update-mobile-location-text-link">
<textLink
id="mobileLocationLinkPromptId"
ref="mobileLocationLink"
linkType="text"
:text="mobileLocationLinkText"
href="#!"
@click-event="openModal" />
</div>
<div
v-show="errorMessage"
class="row my-1 form-test-error">
<span
class="d-inline-flex small mt-0 center-error-message"
aria-atomic="true"
aria-live="polite">
{{ errorMessage }}
</span>
</div>
<textBlock
:customText="mobileFeeText"
cmsWidgetName="MobileFeeDisclaimerWidget"
typeStyle="caption" />
</div>
<modal
:ref="modalName"
:headerText="modalHeaderText"
:footerButtonText="modalFooterText"
:onModalOpenedCallback="onModalOpened"
:onModalClosedCallback="onModalClosed"
@isModalOpened="setModalStatus"
@footer-button-event="setMobileLocation">
<template v-if="isModalOpened">
<addressQuestions
ref="addressQuestions"
v-model="internalModel.addressQuestions"
captureApartmentNumberOrBusinessName="true"
preserveCityAndStateOnReset="true" />
<vehicleProtectedQuestion
ref="vehicleProtectedQuestion"
v-model="internalModel.isVehicleProtected"
cmsWidgetName="VehicleProtectedQuestionWidget" />
<textBlock
cmsWidgetName="WorkspaceRequirementsWidget"
typeStyle="caption" />
<alert
v-if="displayInvalidZipAlert"
ref="alertInvalidZip"
class="my-4"
cmsWidgetName="AlertInvalidZipWidget"
alertClass="alert-danger"
:isDismissible="false" />
</template>
</modal>
</div>
</transition>
</template>
<script>
// Components
import textLink from '@/ux-components/text-link/text-link.vue';
import textBlock from '@/digital-components/text-block/text-block.vue';
import modal from '@/digital-components/modal/modal.vue';
import alert from '@/ux-components/alert/alert.vue';
import { useField } from 'vee-validate';
import addressQuestions from '@/iss-components/address-questions/address-questions.vue';
// eslint-disable-next-line max-len
import vehicleProtectedQuestion from '@/layouts/service-location/mobile-location-modal-questions/vehicle-protected-question/vehicle-protected-question.vue';
// Helpers
import {
getPricedMobileFeePart,
getServiceabilityDetails
} from '@/helpers/service-location-helper';
import { deepClone } from '@/helpers/object-helper.js';
// Validation
export default {
name: 'mobile-location-modal-questions',
components: {
textLink,
modal,
addressQuestions,
vehicleProtectedQuestion,
textBlock,
alert
},
props: {
modelValue: {
type: Object,
default: () => ({
addressQuestions: {
streetAddress: '',
apartmentNumberOrBusinessName: '',
city: '',
state: '',
zipCode: ''
},
isVehicleProtected: null
})
},
mobileFeePart: {
type: Object,
default: () => ({})
},
linkWidgetName: String,
modalWidgetName: String,
alertNonServiceableZipWidgetName: String,
alertInvalidZipWidgetName: String,
customComponentId: String,
validationRules: String,
onZipUpdateCallback: {
type: Function
}
},
emits: ['update:modelValue', 'updated-mobile-fee-part', 'updated-contains-military-base'],
setup(props) {
const uuid = crypto.randomUUID();
const componentId = !props.customComponentId
? `component-${uuid}`
: props.customComponentId;
// Integrate this component as a single field with an object for it's value into the page level validation
const { modelValue } = deepClone(props);
const initialValue = modelValue;
const fieldOptions = {
value: modelValue,
initialValue
};
const { errorMessage, handleChange, meta, validate, errors } = useField(
componentId,
props.validationRules,
fieldOptions
);
return {
componentId,
errorMessage,
handleChange,
validate,
meta,
errors
};
},
data() {
return {
internalModel: deepClone(this.modelValue),
displayInvalidZipAlert: false,
isModalOpened: false
};
},
computed: {
mobileLocationLinkPromptText() {
return this.getCmsContent(this.linkWidgetName, 'HeaderText');
},
mobileLocationLinkText() {
if (
this.addressModel.streetAddress
&& this.addressModel.streetAddress !== ''
&& this.addressModel.city
&& this.addressModel.city !== ''
&& this.addressModel.state
&& this.addressModel.state !== ''
&& this.addressModel.zipCode
&& this.addressModel.zipCode !== ''
&& this.internalModel.isVehicleProtected !== null
) {
// eslint-disable-next-line max-len
return `${this.addressModel.streetAddress}\n${this.addressModel.city}, ${this.addressModel.state} ${this.addressModel.zipCode}`;
}
return this.getCmsContent(this.linkWidgetName, 'BodyText');
},
modalName() {
return 'MobileLocationModalWidget';
},
modalHeaderText() {
return this.getCmsContent(this.modalWidgetName, 'HeaderText');
},
mobileFeeText() {
const cmsContentText = this.getCmsContent('MobileFeeDisclaimerWidget', 'Text');
return cmsContentText.replaceAll('{custom:mobileFee}', this.mobileFee);
},
mobileFee() {
if (!this.mobileFeePart) {
return 0;
}
return (
this.mobileFeePart.laborAmount
+ this.mobileFeePart.sellingPrice
+ this.mobileFeePart.kitPrice
);
},
modalFooterText() {
return this.getCmsContent(this.modalWidgetName, 'FooterText');
},
modal() {
return this.$refs[this.modalName];
},
addressModel: {
get() {
return this.modelValue.addressQuestions;
}
}
},
watch: {
modelValue: {
handler(newValue) {
this.internalModel = deepClone(newValue);
this.handleChange(newValue);
},
deep: true
}
},
methods: {
openModal() {
this.modal.openModal();
},
onModalOpened() {
this.internalModel = deepClone(this.modelValue);
},
setModalStatus(isOpened) {
this.isModalOpened = isOpened;
},
closeModal() {
this.modal.closeModal();
},
onModalClosed() {
this.displayInvalidZipAlert = false;
this.internalModel = deepClone(this.modelValue);
},
resetModalButtonStyle() {
this.modal.resetButtonStyle();
},
async setMobileLocation() {
if (
this.internalModel.addressQuestions.zipCode
!== this.modelValue.addressQuestions.zipCode
) {
// Validate the Zip Code
const zipCodeData = await this.getZipCodeData(this.internalModel.addressQuestions.zipCode);
if (!zipCodeData.isValid) {
this.displayInvalidZipAlert = true;
this.resetModalButtonStyle();
} else {
// retrieve mobile fee part
const serviceZipCode = this.internalModel.addressQuestions.zipCode;
const mobileFeePart = await getPricedMobileFeePart(serviceZipCode);
// retrieve serviceability details
const serviceabilityDetails = await getServiceabilityDetails(serviceZipCode);
// update content related to service zip code
this.$emit('updated-mobile-fee-part', mobileFeePart);
this.$emit('updated-serviceability', serviceabilityDetails.data);
this.$emit('updated-contains-military-base', zipCodeData.containsMilitaryBase);
if (this.onZipUpdateCallback) {
await this.onZipUpdateCallback(serviceZipCode);
}
// Update the page level model
this.$emit('update:modelValue', this.internalModel);
this.closeModal();
}
} else {
// Update the page level model
this.$emit('update:modelValue', this.internalModel);
this.closeModal();
}
}
}
};
</script>
<style lang="scss" scoped>
.mobile-location-questions {
.center-error-message {
justify-content: center !important;
}
}
.update-zip-text-link::before {
content: "";
display: inline-block;
width: 13px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.49635 1.00142e-07C5.64734 -0.000153295 4.80722 0.175918 4.0274 0.517444C3.24757 0.858969 2.54443 1.35877 1.96099 1.98626C0.765713 3.27588 0.0999756 4.98141 0.0999756 6.75394C0.0999756 8.52646 0.765713 10.232 1.96099 11.5216L5.98324 15.777C6.04954 15.8475 6.12918 15.9036 6.21736 15.9419C6.30555 15.9802 6.40045 16 6.49635 16C6.59225 16 6.68716 15.9802 6.77534 15.9419C6.86353 15.9036 6.94317 15.8475 7.00946 15.777L11.0317 11.52C12.2391 10.2383 12.909 8.52914 12.8999 6.75394C12.9094 4.97818 12.2394 3.26832 11.0317 1.98626C10.4481 1.35899 9.74493 0.859347 8.96514 0.517839C8.18535 0.176331 7.34532 0.000130509 6.49635 1.00142e-07V1.00142e-07ZM6.49635 9.13131C6.02507 9.13131 5.56437 8.98913 5.17251 8.72275C4.78065 8.45637 4.47524 8.07776 4.29488 7.63479C4.11453 7.19181 4.06734 6.70438 4.15928 6.23412C4.25123 5.76387 4.47817 5.33191 4.81142 4.99287C5.14467 4.65384 5.56925 4.42295 6.03148 4.32941C6.49371 4.23587 6.97282 4.28388 7.40823 4.46736C7.84364 4.65085 8.21579 4.96157 8.47762 5.36023C8.73945 5.7589 8.87921 6.2276 8.87921 6.70707C8.87921 7.34974 8.62837 7.96611 8.18185 8.4207C7.73532 8.87528 7.12964 9.13088 6.49794 9.13131H6.49635Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
background-size: contain;
vertical-align: middle;
margin-right: 0.5em;
}
.address-questions {
margin-top: 0em;
}
#mobileLocationLinkPromptId {
white-space: pre-line;
}
.text-black {
color: $black;
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<buttonQuestion
v-model="selectedValue"
class="radioQuestion"
:questionText="questionText"
:answers="answers"
groupName="isVehicleProtected"
textPosition="text-center"
validationRules="option-required"
isRequired />
</template>
<script>
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
// Supporting files
import { defineRule } from 'vee-validate';
import { required } from '@/helpers/validation-rules';
import errorMessages from '@/constants/error-messages';
defineRule('option-required', required(errorMessages.OPTION_REQUIRED));
export default {
name: 'vehicle-protected-question',
components: {
buttonQuestion
},
props: {
// eslint-disable-next-line vue/require-prop-types
modelValue: {
isVehicleProtected: Boolean
},
cmsWidgetName: String
},
emits: ['update:modelValue'],
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValue: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
}
},
answers() {
return this.getCmsContent(this.cmsWidgetName, 'Answers');
}
}
};
</script>
<style lang="scss">
.modal-dialog {
.question-text {
& > span {
text-align: left;
}
}
}
</style>

View file

@ -5,52 +5,88 @@
@submit="onSubmit"
@invalidSubmit="onInvalidSubmit">
<div class="page-container-grouped-styles">
<div class="fade-on-route-transition position-relative">
<siteHeader cmsWidgetName="SiteHeaderWidget" />
<siteSubHeader
id="sub-header"
cmsWidgetName="SiteSubHeaderWidget"
class="mt-5" />
<div class="select-car">
<div class="container-fluid pb-2">
<div class="row px-3">
<div class="col">
<div class="select-car-form rounded">
<serviceZipModalQuestion
ref="serviceZipCodeQuestion"
v-model="serviceZipCodeQuestion"
modalWidgetName="ServiceZipModalWidget"
@updatedServiceability="setServiceabilityDetails"
@updatedContainsMilitaryBase="setContainsMilitaryBase" />
<alert
v-if="displayMilitaryZipAlert"
ref="alertMilitaryBaseZip"
class="my-5"
cmsWidgetName="AlertMilitaryBaseZipWidget"
alertClass="alert-warning" />
<buttonQuestion
v-model="selectedAppointmentType"
cmsWidgetName="ServiceTypeQuestionWidget"
:questionText="questionText"
:answers="answersFromCms"
buttonTypeString="listCard"
isRequired
validationRules="selection-required"
class="service-location-button-question mb-1" />
<div class="select-car">
<siteFooter
ref="siteFooter"
class="mt-5"
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
@backClicked="backButtonAction"
@forwardClicked="forwardButtonAction" />
</div>
</div>
</div>
</div>
</div>
</div>
<siteHeader cmsWidgetName="SiteHeaderWidget" />
<siteSubHeader
cmsWidgetName="SiteSubHeaderWidget"
class="mt-5" />
<div class="main-content-container">
<serviceZipModalQuestion
ref="serviceZipCodeQuestion"
v-model="serviceZipCodeQuestion"
modalWidgetName="ServiceZipModalWidget"
@updatedServiceability="setServiceabilityDetails"
@updatedContainsMilitaryBase="setContainsMilitaryBase" />
<alert
v-if="displayMilitaryZipAlert"
ref="alertMilitaryBaseZip"
class="my-5"
cmsWidgetName="AlertMilitaryBaseZipWidget"
alertClass="alert-warning" />
<alert
v-if="displayServiceableMobileOnly"
ref="alertMobileOnly"
class="my-5"
cmsWidgetName="AlertMobileOnlyWidget"
alertClass="alert-warning" />
<alert
v-if="displayRecalibrationWarning"
ref="alertRecalNoMobile"
class="my-5"
cmsWidgetName="AlertRecalNoMobileWidget"
alertClass="alert-warning"
@text-link-clicked="openModalAction" />
<alert
v-if="displayServiceableInshopOnly"
ref="alertInshopOnly"
class="my-5"
cmsWidgetName="AlertInshopOnlyWidget"
alertClass="alert-warning" />
<alert
v-if="displayNoShopsAlert"
ref="alertNoShops"
class="my-5"
cmsWidgetName="AlertNoShopsWidget"
alertClass="alert-warning" />
<appointmentTypeQuestion
v-show="isAppointmentTypeDisplayed"
ref="appointmentTypeQuestion"
v-model="selectedAppointmentType"
:isServiceableMobile="isServiceableMobile"
:isServiceableInshop="isServiceableInshop"
:isDisplayed="isAppointmentTypeDisplayed"
groupName="appointmentTypeQuestion"
cmsWidgetName="AppointmentTypeQuestionWidget"
validationRules="option-required" />
<mobileLocationModalQuestions
v-if="selectedAppointmentType === 'Mobile'"
ref="mobileLocationQuestions"
v-model="mobileLocationQuestions"
customComponentId="mobileLocationQuestions"
:mobileFeePart="mobileFeePart"
validationRules="mobile-location-required"
linkWidgetName="MobileLocationLinkWidget"
modalWidgetName="MobileLocationModalWidget"
:onZipUpdateCallback="reloadShopData"
@updated-mobile-fee-part="setMobileFeePart"
@updated-serviceability="setServiceabilityDetails"
@updated-contains-military-base="setContainsMilitaryBase" />
<shopQuestion
v-show="isShopQuestionDisplayed"
ref="shopQuestion"
v-model="selectedProvider"
:selectedAppointmentType="selectedAppointmentType"
:isDisplayed="isShopQuestionDisplayed"
cmsWidgetName="ShopQuestionWidget" />
<contentGroupModal
ref="RecalModal"
cmsWidgetName="RecalModal" />
<siteFooter
ref="siteFooter"
class="mt-5"
cmsWidgetName="SiteFooterWidget"
:isForwardActionDisabled="!meta.valid"
@backClicked="backButtonAction"
@forwardClicked="forwardButtonAction" />
</div>
</div>
</Form>
@ -61,32 +97,50 @@ import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import settleAllPromises from '@/helpers/layout-helper';
import { required } from '@/helpers/validation-rules';
import errorMessages from '@/constants/error-messages';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
import { useMainStore } from '@/store';
import { getServiceabilityDetails, getZipCodeData } from '@/helpers/service-location-helper';
import { getPricedMobileFeePart, getServiceabilityDetails, getZipCodeData } from '@/helpers/service-location-helper';
// Import Component
import alert from '@/ux-components/alert/alert.vue';
import appointmentTypeQuestion from '@/layouts/service-location/appointment-type-question/appointment-type-question.vue';
import baseFormMixin from '@/mixins/base-form-mixin';
import contentGroupModal from '@/iss-components/content-group-modal/content-group-modal.vue';
import mobileLocationModalQuestions from '@/layouts/service-location/mobile-location-modal-questions/mobile-location-modal-questions.vue';
import { Form, defineRule } from 'vee-validate';
import shopQuestion from '@/layouts/service-location/shop-question/shop-question.vue';
import siteFooter from '@/iss-components/site-footer/site-footer.vue';
import siteHeader from '@/iss-components/site-header/site-header.vue';
import siteSubHeader from '@/iss-components/site-sub-header/site-sub-header.vue';
import serviceZipModalQuestion from '@/layouts/service-location/service-zip-modal-question/service-zip-modal-question.vue';
// DEFINE VALIDATION RULES
defineRule('mobile-location-required', (value) => {
if (
value.addressQuestions.streetAddress === ''
|| value.addressQuestions.city === ''
|| value.addressQuestions.state === ''
|| value.addressQuestions.zipCode === ''
|| value.isVehicleProtected == null
) {
return errorMessages.MOBILE_LOCATION_REQUIRED;
}
return true;
});
defineRule('selection-required', required(errorMessages.OPTION_REQUIRED));
export default {
name: 'service-location',
components: {
alert,
appointmentTypeQuestion,
contentGroupModal,
mobileLocationModalQuestions,
siteFooter,
siteHeader,
siteSubHeader,
buttonQuestion,
// eslint-disable-next-line vue/no-reserved-component-names
Form,
serviceZipModalQuestion,
alert
shopQuestion
},
mixins: [baseFormMixin],
async beforeRouteEnter(to, from, next) {
@ -96,7 +150,9 @@ export default {
const serviceZipCode = useMainStore().order.customer.address.zipCode;
const zipCodeData = getZipCodeData(serviceZipCode);
const mobileFeePartPromise = getPricedMobileFeePart(serviceZipCode);
const serviceabilityDetailsPromise = getServiceabilityDetails(serviceZipCode);
const shopQuestionInitialDataPromise = shopQuestion.methods.loadInitialData(serviceZipCode);
// Settle promises and get results
const promiseResultMap = [
@ -104,10 +160,18 @@ export default {
resultKey: 'cmsContent',
promise: cmsContentPromise
},
{
resultKey: 'mobileFeePart',
promise: mobileFeePartPromise
},
{
resultKey: 'serviceabilityDetails',
promise: serviceabilityDetailsPromise
},
{
resultKey: 'shopQuestionInitialData',
promise: shopQuestionInitialDataPromise
},
{
resultKey: 'zipCodeData',
promise: zipCodeData
@ -115,10 +179,10 @@ export default {
];
const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.setData(resultMap.zipCodeData, resultMap.serviceabilityDetails);
vm.setData(resultMap.zipCodeData, resultMap.serviceabilityDetails, resultMap.mobileFeePart);
vm.$refs.shopQuestion.initializeComponent(resultMap.shopQuestionInitialData);
});
},
setup() {
@ -127,13 +191,21 @@ export default {
},
data() {
return {
zipCode: this.mainStore.order.customer.address.zipCode,
state: this.mainStore.order.customer.address.state,
isServiceZipServiceable: null,
streetAddress: this.getServiceAddressFromStore(),
apartmentNumberOrBusinessName: this.getServiceAddress2FromStore(),
city: this.getServiceCityFromStore(),
state: this.getServiceStateFromStore(),
zipCode: this.getServiceZipCodeFromStore(),
isVehicleProtected: this.getIsVehicleProtectedFromStore(),
isGlassServiceableInshop: null,
isRecalibrationServiceableInshop: null,
isGlassServiceableMobile: null,
isRecalibrationServiceableMobile: null,
selectedAppointmentType: '',
zipContainsMilitaryBase: false
selectedAppointmentType: this.getSelectedAppointmentType(),
selectedProvider: this.getSelectedProvider(),
mobileFeePart: null,
zipContainsMilitaryBase: false,
zipCodeCtu: null
};
},
computed: {
@ -146,26 +218,102 @@ export default {
serviceZipCodeQuestion: {
get() {
return {
zipCode: this.zipCode,
state: this.state
state: this.state,
zipCode: this.zipCode
};
},
set(newValue) {
this.zipCode = newValue.zipCode;
this.state = newValue.state;
if (newValue.zipCode !== this.zipCode) {
this.resetMobileLocation();
this.selectedAppointmentType = null;
this.selectedProvider = null;
}
// TODO: review - this seems like it should be awaited.
this.state = newValue.state;
this.zipCode = newValue.zipCode;
// eslint-disable-next-line vue/valid-next-tick
this.$nextTick();
}
},
mobileLocationQuestions: {
get() {
return {
addressQuestions: {
streetAddress: this.streetAddress,
apartmentNumberOrBusinessName: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode
},
isVehicleProtected: this.isVehicleProtected
};
},
set(newValue) {
this.streetAddress = newValue.addressQuestions.streetAddress;
this.apartmentNumberOrBusinessName =
newValue.addressQuestions.apartmentNumberOrBusinessName;
this.city = newValue.addressQuestions.city;
this.state = newValue.addressQuestions.state;
this.zipCode = newValue.addressQuestions.zipCode;
this.isVehicleProtected = newValue.isVehicleProtected;
if (newValue.zipCode !== this.zipCode) {
if (!this.selectedAppointmentType === 'Mobile') {
this.selectedAppointmentType = null;
}
this.selectedProvider = null;
}
}
},
isServiceableMobile() {
if (this.isRecalibrationServiceableMobile !== null) {
return this.isGlassServiceableMobile && this.isRecalibrationServiceableMobile;
}
return this.isGlassServiceableMobile;
},
isServiceableInshop() {
if (this.isRecalibrationServiceableInshop !== null) {
return this.isGlassServiceableInshop && this.isRecalibrationServiceableInshop;
}
return this.isGlassServiceableInshop;
},
isShopQuestionDisplayed() {
return (
this.selectedAppointmentType === 'Inshop'
|| this.selectedAppointmentType === 'Dropoff'
);
},
isAppointmentTypeDisplayed() {
return true; // this.zipCode && !this.displayNoShopsAlert;
},
requiresInshopRecalibration() {
// Specifically check for isRecalibrationServiceableMobile === false, not null or true.
return (
this.isServiceableInshop
&& this.isGlassServiceableMobile
&& this.isRecalibrationServiceableMobile === false
);
},
displayMilitaryZipAlert() {
return this.zipContainsMilitaryBase && this.isServiceableMobile;
},
displayNoShopsAlert() {
return !this.isServiceableInshop && !this.isServiceableMobile;
},
displayRecalibrationWarning() {
return this.requiresInshopRecalibration;
},
displayServiceableInshopOnly() {
return (
!this.displayRecalibrationWarning
&& this.isServiceableInshop
&& !this.isServiceableMobile
);
},
displayServiceableMobileOnly() {
return this.isServiceableMobile && !this.isServiceableInshop;
}
},
methods: {
@ -182,50 +330,108 @@ export default {
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
async forwardButtonAction() {
// validate and save data here
useMainStore().saveServiceLocation({
address: this.streetAddress,
address2: this.apartmentNumberOrBusinessName,
city: this.city,
state: this.state,
zipCode: this.zipCode,
zipCodeCtu: this.zipCodeCtu,
appointmentType: this.selectedAppointmentType,
isVehicleProtected: this.isVehicleProtected,
provider: {
providerNumber: this.selectedProvider?.providerNumber,
address: {
streetAddress: this.selectedProvider?.address?.streetAddress,
city: this.selectedProvider?.address?.city,
state: this.selectedProvider?.address?.state,
zipCode: this.selectedProvider?.address?.zipCode,
zipCodeCtu: this.selectedProvider?.address?.zipCodeCtu
}
}
});
this.$router.navigate(this.navigationScenarios.CLICKED_FORWARD, this.$route);
},
openModalAction(modalName) {
this.$refs[modalName].openModal();
},
resetDependentState() {
},
setData(zipCodeData, serviceabilityDetails) {
getServiceAddressFromStore() {
return useMainStore().order.serviceLocation.address;
},
getServiceAddress2FromStore() {
return useMainStore().order.serviceLocation.address2;
},
getServiceCityFromStore() {
return useMainStore().order.serviceLocation.city;
},
getServiceStateFromStore() {
return useMainStore().order.serviceLocation.state || useMainStore().order.customer.address.state;
},
getServiceZipCodeFromStore() {
return useMainStore().order.serviceLocation.zipCode || useMainStore().order.customer.address.zipCode;
},
getIsVehicleProtectedFromStore() {
return useMainStore().order.serviceLocation.isVehicleProtected;
},
getSelectedAppointmentType() {
return useMainStore().order.serviceLocation.appointmentType;
},
getSelectedProvider() {
return useMainStore().order.serviceLocation.provider;
},
setData(zipCodeData, serviceabilityDetails, mobileFeePart) {
if (zipCodeData) {
this.zipContainsMilitaryBase = zipCodeData.containsMilitaryBase;
}
if (serviceabilityDetails) {
this.setServiceabilityDetails(serviceabilityDetails);
}
if (mobileFeePart) {
this.mobileFeePart = mobileFeePart;
}
},
setContainsMilitaryBase(val) {
if (this.zipContainsMilitaryBase !== val) {
this.zipContainsMilitaryBase = val;
}
},
setMobileFeePart(mobileFeePart) {
this.mobileFeePart = mobileFeePart;
},
resetMobileLocation() {
this.streetAddress = '';
this.apartmentNumberOrBusinessName = '';
this.city = '';
this.isVehicleProtected = null;
},
setServiceabilityDetails(serviceabilityDetails) {
this.isGlassServiceableInshop = serviceabilityDetails.isGlassServiceableInshop;
this.isRecalibrationServiceableInshop =
serviceabilityDetails.isRecalibrationServiceableInshop;
this.isRecalibrationServiceableInshop = serviceabilityDetails.isRecalibrationServiceableInshop;
this.isGlassServiceableMobile = serviceabilityDetails.isGlassServiceableMobile;
this.isRecalibrationServiceableMobile =
serviceabilityDetails.isRecalibrationServiceableMobile;
this.isRecalibrationServiceableMobile = serviceabilityDetails.isRecalibrationServiceableMobile;
}
}
};
</script>
<style lang="scss" scoped>
$page-side-padding: 1.5rem;
.page-container-grouped-styles {
overflow: auto;
}
.modal-open {
.page-container-grouped-styles {
overflow: hidden;
.main-content-container {
padding: 0 1.5rem !important;
}
}
#sub-header span {
color: $black;
}
.question-text {
& > span {
line-height: 1.5rem;
@ -239,7 +445,6 @@ export default {
line-height: 1.250rem;
}
}
.choose-option{
.button-question >div {
&:first-of-type {

View file

@ -0,0 +1,205 @@
<template>
<transition
name="fade"
mode="out-in">
<baseInputButton
v-bind="$props"
v-model="selectedValue"
buttonWrapperClasses="list-group base-input-button list-button rounded-3 d-flex flex-column w-100 mb-2">
<div
:aria-label="buttonLabel"
class="button-content list-button-content d-flex flex-column justify-content-center py-3 px-4">
<div class="row-one">
<span
class="m-0 button-label-copy"
:class="textPosition">{{
buttonLabel
}}</span>
<span
class="m-0 caption ms-1"
:class="textPosition">{{
buttonLabelSubCopy
}}</span>
</div>
<div class="row-two">
<span
v-if="buttonBodyCopy"
class="m-0 button-label-sub-copy small"
v-html="buttonBodyCopy"></span>
</div>
<span
v-if="screenReaderOnlyText"
class="sr-only">
{{ screenReaderOnlyText }}
</span>
</div>
</baseInputButton>
</transition>
</template>
<script>
import baseInputButton from '@/digital-components/base-input-button/base-input-button.vue';
import inputButtonWrapperMixin from '@/mixins/input-button-wrapper-mixin';
export default {
name: 'shop-list-button',
components: {
baseInputButton
},
mixins: [inputButtonWrapperMixin],
data() {
return {
availabilityRating: null
};
},
computed: {
isLoaderDisplayed() {
return this.availabilityRating == null;
},
availabilityRatingClass() {
if (this.availabilityRating == null) {
return 'gray';
}
return this.availabilityRating === 'high' ? 'green' : 'orange';
},
badgeText() {
if (this.availabilityRating != null) {
return this.availabilityRating === 'high' ? 'Appts available' : 'Appts low';
}
return '';
}
},
methods: {
displayLoader() {
this.isLoaderDisplayed = true;
}
}
};
</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;
}
}
}
.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 {
&.small {
font-size: 0.75rem;
color: $gray-550;
}
}
}
.button-content {
row-gap: 0.25rem;
.row-one {
display: flex;
align-items: center;
line-height: 1.5rem;
.button-label-copy {
font-weight: 500;
}
.availability-indicator {
background-repeat: no-repeat;
display: flex;
align-items: center;
margin-left: auto;
padding: 0.125rem 0.5rem;
.availability-badge {
display: inline;
width: 13px;
height: 12px;
background-position: center;
background-repeat: no-repeat;
margin: 0 0.25rem 0 0;
&.green {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.5 12C3.19159 12 0.5 9.30841 0.5 6C0.5 2.69159 3.19159 0 6.5 0C9.80841 0 12.5 2.69159 12.5 6C12.5 9.30841 9.80841 12 6.5 12ZM6.5 0.785047C3.62449 0.785047 1.28505 3.12449 1.28505 6C1.28505 8.87551 3.62449 11.215 6.5 11.215C9.37551 11.215 11.715 8.87551 11.715 6C11.715 3.12449 9.37551 0.785047 6.5 0.785047Z' fill='%23006A36'/%3E%3Cpath d='M5.697 7.95252C5.5927 7.95252 5.49289 7.91102 5.41999 7.837L3.90597 6.32299C3.75233 6.16934 3.75233 5.92149 3.90597 5.76785C4.05962 5.6142 4.30747 5.6142 4.46111 5.76785L5.69812 7.00373L8.53999 4.16186C8.69364 4.00822 8.94149 4.00822 9.09513 4.16186C9.24878 4.31551 9.24878 4.56336 9.09513 4.717L5.97626 7.83588C5.90224 7.9099 5.80242 7.9514 5.69925 7.9514L5.697 7.95252Z' fill='%23006A36'/%3E%3C/svg%3E%0A");
}
&.orange {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 13 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.5 0C9.81368 0 12.5 2.68632 12.5 6C12.5 9.31368 9.81368 12 6.5 12C3.18632 12 0.5 9.31368 0.5 6C0.5 2.68632 3.18632 0 6.5 0ZM6.5 0.84C3.6548 0.84 1.34 3.1548 1.34 6C1.34 8.8452 3.6548 11.16 6.5 11.16C9.3452 11.16 11.66 8.8452 11.66 6C11.66 3.1548 9.3452 0.84 6.5 0.84ZM7.90018 4.00596C8.06422 3.84204 8.33002 3.84192 8.49406 4.00596C8.6581 4.17 8.6581 4.43592 8.49406 4.59996L7.0939 6L8.49406 7.40004C8.6581 7.56408 8.6581 7.83 8.49406 7.99404C8.4121 8.076 8.30458 8.11704 8.19706 8.11704C8.08966 8.11704 7.98214 8.076 7.90018 7.99404L6.50002 6.594L5.09986 7.99404C5.01778 8.076 4.91038 8.11704 4.80286 8.11704C4.69546 8.11704 4.58794 8.076 4.50598 7.99404C4.34182 7.83 4.34182 7.56408 4.50598 7.40004L5.90614 6L4.50598 4.59996C4.34182 4.43592 4.34182 4.17 4.50598 4.00596C4.66978 3.84192 4.93582 3.84204 5.09986 4.00596L6.50002 5.406L7.90018 4.00596Z' fill='%23E86421'/%3E%3C/svg%3E%0A");
}
}
.button-auxillary-copy {
box-sizing: border-box;
justify-content: right;
line-height: 1.25rem;
font-weight: 500;
font-size: 0.75rem;
align-items: center;
}
.loader {
padding: 0 0.25rem 0 0.25rem;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.loader:after {
background-color: $gray-550;
height: 1rem;
width: 1rem;
}
&.green {
color: $green-700;
background-color: $green-100;
}
&.orange {
color: $orange-600;
background-color: $orange-100;
}
&.gray {
color: $gray-600;
background-color: $gray-100;
padding-left: 0.125rem;
padding-right: 0.125rem;
}
}
}
.row-two {
text-align: left;
}
}
</style>

View file

@ -0,0 +1,284 @@
<template>
<transition
name="fade"
mode="out-in">
<div
v-if="isDisplayed"
class="shop-question"
aria-live="polite">
<alert
v-if="displayDropoffInformation"
ref="alertDropoffInformation"
class="mb-4 drop-off-alert"
cmsWidgetName="AlertDropoffInformationWidget"
alertClass="alert-info"
:isDismissible="false" />
<buttonQuestion
ref="buttonQuestion"
v-model="selectedProviderNumber"
buttonTypeString="shopListButton"
:buttonTypeObject="shopListButton"
class="radioQuestion"
:questionText="questionText"
:answers="answers"
groupName="chooseShop"
textPosition="text-start"
isRequired
validationRules="option-required"
:additionalButtonData="additionalButtonData" />
<textLink
v-show="displaySeeMoreLocationsLink"
id="showMoreShopsId"
ref="showMoreShopsLink"
class="show-more-shops-link"
cmsWidgetName="ShowMoreShopsLinkWidget"
linkType="text"
:text="showMoreShopsLinkText"
href="#!"
:aria-label="showMoreShopsLinkText"
@click-event="getNextShopsFromList" />
</div>
</transition>
</template>
<script>
// Components
import alert from '@/ux-components/alert/alert.vue';
import buttonQuestion from '@/digital-components/button-question/button-question.vue';
import textLink from '@/ux-components/text-link/text-link.vue';
// Supporting files
import baseMixin from '@/mixins/base-mixin.js';
import { defineRule } from 'vee-validate';
import { required } from '@/helpers/validation-rules';
import errorMessages from '@/constants/error-messages';
import { useMainStore } from '@/store/index.js';
import { nextTick } from 'vue';
import { getAvailabilityRating } from '@/helpers/service-location-helper';
import shopListButton from './shop-list-button/shop-list-button.vue';
defineRule('option-required', required(errorMessages.OPTION_REQUIRED));
export default {
name: 'shop-question',
components: {
alert,
buttonQuestion,
textLink
},
mixins: [baseMixin],
props: {
modelValue: {
type: Object,
default: () => null
},
selectedAppointmentType: String,
cmsWidgetName: String,
validationRules: String,
isDisplayed: Boolean
},
emits: ['update:modelValue'],
data() {
return {
shopProviders: [],
shopListButton,
answers: [],
shopIndex: 0,
displaySeeMoreLocationsLink: false
};
},
computed: {
questionText() {
return this.getCmsContent(this.cmsWidgetName, 'QuestionText');
},
selectedValue: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
}
},
selectedProviderNumber: {
get() {
return this.selectedValue?.providerNumber;
},
set(newValue) {
// Button Question only supports Number, or String data types so we must get the full object to emit
this.selectedValue = this.getSelectedProviderObject(newValue);
}
},
displayDropoffInformation() {
return this.selectedAppointmentType === 'Dropoff';
},
showMoreShopsLinkText() {
return this.getCmsContent('ShowMoreShopsLinkWidget', 'Text');
},
additionalButtonData() {
const startDate = new Date();
const endDate = new Date();
endDate.setDate(startDate.getDate() + 6);
const formattedStartDate = startDate.toISOString().split('T')[0];
const formattedEndDate = endDate.toISOString().split('T')[0];
return {
availabilityRatingCallback: getAvailabilityRating,
startDate: formattedStartDate,
endDate: formattedEndDate,
shopAppointmentType: this.selectedAppointmentType
};
}
},
watch: {
selectedAppointmentType: {
async handler(newValue) {
this.resetAnswers();
await nextTick();
this.selectedProviderNumber = null;
await nextTick();
if (newValue !== 'Mobile') {
this.getNextShopsFromList();
}
}
},
shopProviders: {
async handler(newValue) {
await nextTick();
if (this.selectedAppointmentType) {
const selectedShopIndex = this.getSelectedProviderIndex(
newValue,
this.selectedProviderNumber
);
if (selectedShopIndex >= 3) {
await this.getNextShopsFromList(selectedShopIndex + 1);
} else {
await this.getNextShopsFromList();
await nextTick();
}
}
}
}
},
methods: {
loadInitialData(serviceZipCode) {
return this.loadData(serviceZipCode);
},
loadData(serviceZipCode) {
return useMainStore().getProviders(serviceZipCode);
},
initializeComponent(shopQuestionInitialData) {
this.shopProviders = shopQuestionInitialData.shopProviders;
},
async getNextShopsFromList(numberToGet = 3) {
const shopIterator = (array, n) => {
const l = array.length;
return () => {
const end = this.shopIndex + n;
const part = array.slice(this.shopIndex, end);
this.shopIndex = end < l ? end : this.shopProviders.length;
return part;
};
};
const toTitleCase = (str) => str.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase());
const nextShop = shopIterator(this.shopProviders, numberToGet);
// Map API result data
const mappedData = nextShop().map((shopProvider) => {
const streetAddress = toTitleCase(shopProvider.address.streetAddress);
const city = toTitleCase(shopProvider.address.city);
const { state } = shopProvider.address;
const { zipCode } = shopProvider.address;
const distanceInMiles = Math.round(shopProvider.distanceInMiles * 2) / 2;
return {
buttonLabel: city,
buttonLabelSubCopy: `${distanceInMiles} mi`,
buttonBodyCopy: `${streetAddress}, ${city}, ${state} ${zipCode}`,
value: shopProvider.providerNumber
};
});
if (this.answers.length === 0) {
this.answers = mappedData;
} else {
mappedData.forEach((shop) => {
this.answers.push(shop);
});
}
await nextTick();
if (this.shopIndex === this.shopProviders.length) {
this.displaySeeMoreLocationsLink = false;
} else {
this.displaySeeMoreLocationsLink = true;
}
await nextTick();
this.scrollToPageBottom();
},
resetAnswers() {
this.answers = [];
this.shopIndex = 0;
},
async reloadShopData(serviceZipCode) {
const result = await this.loadData(serviceZipCode);
this.initializeComponent(result.data);
this.resetAnswers();
await nextTick();
await this.getNextShopsFromList();
},
getSelectedProviderObject(providerNumber) {
const provider = this.shopProviders?.find((p) => p.providerNumber === providerNumber);
return provider;
},
getSelectedProviderIndex(providers, selectedProviderNumber) {
const index = providers.findIndex((p) => p.providerNumber === selectedProviderNumber);
return index;
}
}
};
</script>
<style lang="scss">
.shop-question {
margin-top: 1rem;
text-align: center;
.button-question {
.question-text {
margin-top: 0.5rem;
}
}
.drop-off-alert {
background: url("data:image/svg+xml,%3Csvg viewBox='0 0 12 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cg clip-path='url(%23clip0_13957_112512)'%3E%3Cpath d='M5.99865 0C4.81147 4.82643e-07 3.65095 0.352111 2.66392 1.01179C1.67688 1.67146 0.907678 2.60907 0.45361 3.70599C-0.000459241 4.80291 -0.11899 6.00986 0.113013 7.17415C0.345015 8.33845 0.917126 9.40778 1.75697 10.2469C2.59682 11.086 3.66666 11.6571 4.83117 11.8881C5.99567 12.119 7.20251 11.9994 8.29902 11.5443C9.39553 11.0893 10.3324 10.3192 10.9912 9.33159C11.65 8.34396 12.0011 7.18313 12 5.99594C11.9971 4.40566 11.3638 2.88142 10.2388 1.75742C9.11375 0.633431 7.58894 0.00143011 5.99865 0V0ZM5.99865 11.2478C4.96135 11.2473 3.94748 10.9392 3.08518 10.3627C2.22288 9.7861 1.55085 8.96685 1.15401 8.00846C0.75718 7.05006 0.653353 5.99554 0.855656 4.97815C1.05796 3.96077 1.55731 3.02618 2.29061 2.29251C3.0239 1.55884 3.95823 1.059 4.97551 0.856176C5.99279 0.653349 7.04737 0.756633 8.00597 1.15297C8.96457 1.54931 9.78416 2.22092 10.3612 3.08293C10.9382 3.94493 11.2467 4.95864 11.2478 5.99594C11.2478 7.38835 10.6949 8.72377 9.71053 9.70861C8.7262 10.6934 7.39106 11.2471 5.99865 11.2478V11.2478Z' fill='%2306577C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M6.22736 8.84695C6.30613 8.76818 6.35038 8.66135 6.35038 8.54996V5.30996C6.35038 5.19857 6.30613 5.09174 6.22736 5.01298C6.1486 4.93421 6.04177 4.88996 5.93038 4.88996C5.81899 4.88996 5.71216 4.93421 5.63339 5.01298C5.55463 5.09174 5.51038 5.19857 5.51038 5.30996V8.54996C5.51038 8.66135 5.55463 8.76818 5.63339 8.84695C5.71216 8.92571 5.81899 8.96996 5.93038 8.96996C6.04177 8.96996 6.1486 8.92571 6.22736 8.84695ZM5.69704 3.97918C5.76611 4.02533 5.84731 4.04996 5.93038 4.04996C5.98558 4.05012 6.04026 4.03936 6.09129 4.01831C6.14232 3.99726 6.18868 3.96633 6.22771 3.9273C6.26675 3.88827 6.29768 3.8419 6.31873 3.79088C6.33978 3.73985 6.35053 3.68516 6.35038 3.62996C6.35038 3.54689 6.32574 3.46569 6.27959 3.39662C6.23344 3.32755 6.16785 3.27372 6.0911 3.24193C6.01436 3.21014 5.92991 3.20183 5.84844 3.21803C5.76697 3.23424 5.69213 3.27424 5.63339 3.33298C5.57465 3.39171 5.53465 3.46655 5.51845 3.54802C5.50224 3.6295 5.51056 3.71394 5.54235 3.79069C5.57414 3.86743 5.62797 3.93303 5.69704 3.97918Z' fill='%2306577C'/%3E%3C/g%3E%3Cdefs%3E%3CclipPath id='clip0_13957_112512'%3E%3Crect width='12' height='12' fill='white'/%3E%3C/clipPath%3E%3C/defs%3E%3C/svg%3E%0A");
background-repeat: no-repeat;
background-size: 0.75rem;
background-position: 0.5rem 0.75rem;
border-radius: 0.5rem;
display: flex;
flex-direction: row;
padding: 0.5rem 0.5rem 0.5rem 1.5rem !important;
gap: 0.25rem;
.alert-heading {
text-align: left;
font-size: 0.75rem;
line-height: 1.25rem;
}
}
}
</style>

View file

@ -25,6 +25,14 @@ export default {
},
savePageDataToStore(page, data) {
useMainStore().updatePageData({ page, data });
},
scrollToPageTop() {
const container = document.getElementsByClassName('page-container-grouped-styles')[0];
container.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
},
scrollToPageBottom() {
const container = document.getElementsByClassName('page-container-grouped-styles')[0];
container.scrollTo({ top: container.scrollHeight, left: 0, behavior: 'smooth' });
}
},
computed: {

View file

@ -11,6 +11,7 @@ import applicationConfig from '@/constants/application-config';
import issPageValues from '@/router/router-constants/issPage-values';
import damageLocationsSelected from '@/constants/damage-locations-selected';
import coverageStatuses from '@/constants/coverage-statuses';
import { PREMIUM_FEE_PART_TYPE } from '@/constants/schedule-constants';
const storeId = 'main';
@ -76,10 +77,23 @@ const getDefaultState = () => ({
},
serviceLocation: {
address: null,
address2: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null
zipCodeCtu: null,
appointmentType: null,
isVehicleProtected: null,
provider: {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null
}
}
},
lineItems: {
glassParts: null,
@ -92,7 +106,8 @@ const getDefaultState = () => ({
insuranceCoverage: {
isVerified: false,
coverageStatus: coverageStatuses.PENDING
}
},
parentAccountNumber: 0
},
contactInfo: {
firstName: null,
@ -563,6 +578,63 @@ export const useMainStore = defineStore({
}
});
},
getShopTimeSlots(startDate, endDate, shopAppointmentType, providerNumber) {
const { order } = this;
const { vehicle } = this.order;
let lineItems = [
...(order.lineItems.supportingItems ?? []),
...(order.lineItems.vaps ?? []),
...getFlattenedArrayOfLineItemsWithChildParts(order.lineItems.glassParts)
];
lineItems = lineItems.map((lineItem) => ({
partNumber: lineItem.partNumber,
partType: lineItem.partType
}));
const glassPieces = order.damage.glassToReplace
? convertGlassPieceToBackEndCompatibleFormat(order.damage.glassToReplace)
: [];
const payload = {
providerNumber,
startDate,
endDate,
shopAppointmentType,
applicationName: applicationConfig.APPLICATION_NAME,
parentAccountNumber: this.payment.parentAccountNumber,
carId: vehicle.carId,
lineItems,
glassPieces,
eon: order.eon,
coverage: {
status: '',
deductible: 0,
additionalAuthFlag: ''
},
partSelection: {
hasAnsweredPartQuestions: !!order.damage.partQuestionAnswers?.length,
hasAnsweredMoldingQuestions: !!order.damage.moldingQuestionAnswers?.length,
hasAnsweredCapabilityQuestions: !!order.damage.capabilityQuestionAnswers?.length,
hasManuallySelectedParts:
!!this.applicationUser.pageData['vehicle-parts']?.partsOrQuestions
.length
},
vehicle: {
year: vehicle.year,
make: vehicle.make,
model: vehicle.model,
style: vehicle.style,
vin: vehicle.vin ?? ''
}
};
return globalMethods.callHttpClient({
method: endpoints.GetShopTimeSlots.method,
endpoint: endpoints.GetShopTimeSlots.url,
payload,
additionalSuccessEventDataHandler: (response) => provisionalTriggersToString(response.data.provisionalTriggers)
});
},
async getWipers() {
const { carId } = this.order.vehicle;
// WARNING
@ -594,6 +666,16 @@ export const useMainStore = defineStore({
});
},
getProviders(serviceZipCode) {
const damageType = this.damage.isRepair ? 'Repair' : 'Replace';
const shopRadiusInMiles = 100;
return globalMethods.callHttpClient({
method: endpoints.GetProviders.method,
endpoint: `${endpoints.GetProviders.url}/${serviceZipCode}/${damageType}/${shopRadiusInMiles}`
});
},
async getSupportingItems() {
const glassPartsArray = this.order.lineItems.glassParts ?? [];
const { carId } = this.order.vehicle;
@ -650,9 +732,20 @@ export const useMainStore = defineStore({
return availableLineItems;
},
getMobileFeePart() {
const damageType = this.damage.isRepair ? 'Repair' : 'Replace';
const parentAccountNumber = 167132; // TODO: MAKE THIS REAL
const billToAccountNumber = 87291; // TODO: MAKE THIS REAL
return globalMethods.callHttpClient({
method: endpoints.GetMobileFeePart.method,
endpoint: `${endpoints.GetMobileFeePart.url}/${damageType}/${parentAccountNumber}/${billToAccountNumber}`
});
},
getServiceabilityDetails({ serviceZipCode }) {
const lineItemsWithOnlyPartNumbers = this.order.lineItems.glassParts.map((glassPart) => ({
partNumber: glassPart.partNumber
const lineItemsWithOnlyPartNumbers = this.order.lineItems.supportingItems.map((lineItem) => ({
partNumber: lineItem.partNumber
}));
const lineItems = buildQueryStringParameterFromArrayOfComplexObjects(lineItemsWithOnlyPartNumbers, 'lineItems');
@ -692,7 +785,7 @@ export const useMainStore = defineStore({
});
},
setSaveSessionInfo(response){
setSaveSessionInfo(response) {
this.order.referralNumber = response.referralNumber;
this.order.referralSequenceNumber = response.referralSequenceNumber;
this.order.referralDate = response.referralDate;
@ -810,11 +903,11 @@ export const useMainStore = defineStore({
});
},
setSaveSessionPromise(promise){
setSaveSessionPromise(promise) {
this.applicationUser.saveSessionPromise = promise;
},
clearSaveSessionPromise(){
clearSaveSessionPromise() {
this.applicationUser.saveSessionPromise = null;
},
@ -869,11 +962,25 @@ export const useMainStore = defineStore({
},
updateServiceLocation(serviceLocationInfo) {
this.order.serviceLocation.address = serviceLocationInfo.address;
this.order.serviceLocation.city = serviceLocationInfo.city;
this.order.serviceLocation.state = serviceLocationInfo.state;
this.order.serviceLocation.zipCode = serviceLocationInfo.zipCode;
this.order.serviceLocation.zipCodeCtu = serviceLocationInfo.zipCodeCtu;
state.order.serviceLocation.address = serviceLocationInfo.address;
state.order.serviceLocation.address2 = serviceLocationInfo.address2;
state.order.serviceLocation.city = serviceLocationInfo.city;
state.order.serviceLocation.state = serviceLocationInfo.state;
state.order.serviceLocation.zipCode = serviceLocationInfo.zipCode;
state.order.serviceLocation.zipCodeCtu = serviceLocationInfo.zipCodeCtu;
state.order.serviceLocation.appointmentType = serviceLocationInfo.appointmentType;
state.order.serviceLocation.isVehicleProtected = serviceLocationInfo.isVehicleProtected;
state.order.serviceLocation.provider = {
providerNumber: serviceLocationInfo.provider?.providerNumber,
address: {
streetAddress: serviceLocationInfo.provider?.address?.streetAddress,
city: serviceLocationInfo.provider?.address?.city,
state: serviceLocationInfo.provider?.address?.state,
zipCode: serviceLocationInfo.provider?.address?.zipCode,
zipCodeCtu: serviceLocationInfo.provider?.address?.zipCodeCtu
}
};
},
resetRegistrationState() {
@ -885,6 +992,28 @@ export const useMainStore = defineStore({
this.order.vehicle.registration.firstName = null;
this.order.vehicle.registration.lastName = null;
},
resetServiceLocationAppointmentType() {
this.order.serviceLocation.appointmentType = null;
},
resetServiceLocationProvider() {
this.order.serviceLocation.provider = {
providerNumber: null,
address: {
streetAddress: null,
city: null,
state: null,
zipCode: null,
zipCodeCtu: null
}
};
},
resetServiceLocationMobileAddress() {
this.order.serviceLocation.address = null;
this.order.serviceLocation.address2 = null;
this.order.serviceLocation.city = null;
this.order.serviceLocation.state = null;
this.order.serviceLocation.isVehicleProtected = null;
},
updateSupportingItems(partsData) {
this.order.lineItems.supportingItems = partsData;
@ -951,7 +1080,23 @@ export const useMainStore = defineStore({
this.applicationUser.pageData[issPageValues.MOLDING_QUESTIONS] = null;
this.applicationUser.pageData[issPageValues.CAPABILITY_QUESTIONS] = null;
},
resetSchedule() {
this.order.schedule.date = null;
this.order.schedule.startTime = null;
this.order.schedule.endTime = null;
this.order.schedule.routeCode = null;
this.order.schedule.jobMaxMinutes = null;
// this.order.schedule.jobMinMinutes = null;
// premium appointment fee used on schedule page also needs reset when schedule is reset
const { supportingItems } = this.order.lineItems;
const premiumAppointmentFeeIndex = supportingItems?.findIndex((item) => item.partType === PREMIUM_FEE_PART_TYPE);
if (premiumAppointmentFeeIndex >= 0) {
supportingItems.splice(premiumAppointmentFeeIndex, 1);
state.order.lineItems.supportingItems = supportingItems;
}
},
resetSupportingItemsState() {
this.order.lineItems.supportingItems = null;
},
@ -1148,6 +1293,47 @@ export const useMainStore = defineStore({
saveVaps(vaps) {
this.order.lineItems.vaps = vaps;
},
// Price order actions
async priceOrderItemsAndSaveServerData(availableLineItems, serviceZipCode, serviceZipCodeCtu) {
const zipCodeToUse = serviceZipCode || this.order.serviceLocation.zipCode;
const ctuToUse = serviceZipCodeCtu || this.order.serviceLocation.zipCodeCtu;
const flattenedLineItemsWithChildParts = getFlattenedArrayOfLineItemsWithChildParts(availableLineItems);
const lineItemsWithOnlyPartNumbers = flattenedLineItemsWithChildParts.map((lineItem) => ({
partNumber: lineItem.partNumber
}));
const availableLineItemsFormattedForRequest =
buildQueryStringParameterFromArrayOfComplexObjects(
lineItemsWithOnlyPartNumbers,
'lineItems'
);
const { vehicle } = this.order;
let queryString =
`ParentAccountNumber=${applicationConfig.CASH_PARENT_ACCOUNT_NUMBER}`
+ `&CTU=${ctuToUse}`
+ `&CarId=${vehicle.carId}`
+ `&Make=${vehicle.make}`
+ `&Model=${vehicle.model}`
+ `&Year=${vehicle.year}`
+ `&EON=${this.order.eon}`
+ `&ZipCode=${zipCodeToUse}`
+ `&${availableLineItemsFormattedForRequest}`;
const lineItemServerData = this.order.lineItems.serverData;
if (lineItemServerData) {
queryString += `&ServerData=${encodeURIComponent(lineItemServerData)}`;
}
const response = await globalMethods.callHttpClient({
method: endpoints.GetPriceOrderItems.method,
endpoint: `${endpoints.GetPriceOrderItems.url}?${queryString}`
});
// context.commit(storeMutations.UPDATE_LINE_ITEMS_SERVER_DATA, response.data.serverData);
return addPricesToLineItems(availableLineItems, response.data.lineItems);
},
saveProviderPreferenceData(data) {
this.updatePageData({ page: issPageValues.PROVIDER_PREFERENCE, data });
},
@ -1339,6 +1525,19 @@ export const useMainStore = defineStore({
},
saveServiceLocation(serviceLocationInfo) {
if (this.order.serviceLocation) {
if (
serviceLocationInfo.zipCode !== this.order.serviceLocation.zipCode
|| !providersEqual(
serviceLocationInfo.provider,
this.order.serviceLocation.provider
)
|| serviceLocationInfo.appointmentType
!== this.order.serviceLocation.appointmentType
) {
this.resetSchedule();
}
}
this.updateServiceLocation(serviceLocationInfo);
},
@ -1549,6 +1748,20 @@ function getLineItemQueryStringForPricing(lineItems) {
}).join('');
}
function getFlattenedArrayOfLineItemsWithChildParts(lineItems) {
let flattenedArray = [];
lineItems?.forEach((lineItem) => {
flattenedArray.push(lineItem);
if (lineItem.childParts) {
flattenedArray = [
...flattenedArray,
...getFlattenedArrayOfLineItemsWithChildParts(lineItem.childParts)
];
}
});
return flattenedArray;
}
function buildQueryStringParameterFromArrayOfComplexObjects(arrayOfObjects, parameterName) {
let queryStringParameter = '';
for (let i = 0; i < arrayOfObjects.length; i++) {
@ -1559,3 +1772,25 @@ function buildQueryStringParameterFromArrayOfComplexObjects(arrayOfObjects, para
// Remove trailing &
return queryStringParameter.slice(0, -1);
}
function convertGlassPieceToBackEndCompatibleFormat(glassPieces) {
return glassPieces.map((glassPiece) => ({
location: glassPiece.glassLocation,
name: glassPiece.glassName
}));
}
function providersEqual(providerA, providerB) {
return (
providerA.providerNumber === providerB.providerNumber
&& providerA.address?.city === providerB.address?.city
&& providerA.address?.state === providerB.address?.state
&& providerA.address?.streetAddress === providerB.address?.streetAddress
&& providerA.address?.zipCode === providerB.address?.zipCode
);
// TODO: Change back to deepEqual once zipCodeCtu is added to saveSession.
}
function provisionalTriggersToString(provisionalTriggers) {
return `ProvisionalTriggers:${provisionalTriggers.join(',')}`;
}

View file

@ -4,28 +4,35 @@ body {
font-size: 16px;
background-color: #fff;
color: #4D5151;
.container-fluid {
max-width: 576px; //Remove once desktop app is complete
&.container-shadow {
box-shadow: 0px 0px 6px 0px rgba(0,0,0,0.15); //Use instead of Bootstrap's helper
box-shadow: 0px 0px 6px 0px rgba(0, 0, 0, 0.15); //Use instead of Bootstrap's helper
}
&.make-tall {
height: 100vh;
display: flex;
flex-direction: column;
}
.prevent-squish{
.prevent-squish {
overflow-x: unset;
}
}
.pointer {
cursor: pointer;
}
.container,
.container-fluid {
overflow: hidden;
}
.sub-container{
.sub-container {
&.make-tall {
height: 100%;
width: 100%;
@ -34,6 +41,7 @@ body {
overflow-x: hidden;
}
}
.sr-only {
position: absolute;
left: -10000px;
@ -42,21 +50,29 @@ body {
height: 1px;
overflow: hidden;
}
.page-container-grouped-styles {
@extend .container-fluid, .shadow, .p-0, .position-relative, .make-tall;
}
//Footer modal backdrop adjustments for positioning
.modal-backdrop {
left: 50%;
transform: translateX(-50%);
max-width: 576px;
height: calc(100% - 72px);
&.show {
opacity: 0.4;
}
}
// Scroll page when modal isn't open
.fade-on-route-transition {
height: calc(100% - 10px);
overflow: auto;
}
// Prevent scroll when modal is open
&.modal-open {
.fade-on-route-transition {
@ -73,4 +89,4 @@ body {
line-height: 24px;
font-weight: 500;
}
}
}

View file

@ -8,27 +8,38 @@ body {
//Headings
//add helper fw-bold to any element to get bold style (500)
h1,.h1 {
h1,
.h1 {
line-height: 1.325;
font-weight: 300;
}
h2,.h2 {
h2,
.h2 {
line-height: 1.325;
font-weight: 300;
}
h3,.h3 {
h3,
.h3 {
line-height: 1.375;
font-weight: 300;
}
h4,.h4 {
h4,
.h4 {
line-height: 1.6;
font-weight: 300;
}
h5,.h5 {
line-height: 1.6;
h5,
.h5 {
line-height: 1.325;
font-weight: 400;
}
h6,.h6 {
h6,
.h6 {
line-height: 1.7;
letter-spacing: .75px;
text-transform: uppercase;
@ -64,4 +75,4 @@ caption,
font-size: 1rem !important;
line-height: 1.4;
font-weight: 500;
}
}

View file

@ -96,7 +96,7 @@ describe('alert.vue', () => {
expect(wrapper.vm.alertCopy).toBe('testCopy');
});
it('Should container a <router-link> tag if the manualCopy contains a {routerLink: testName, testLink} placeholder', () => {
it.skip('Should container a <router-link> tag if the manualCopy contains a {routerLink: testName, testLink} placeholder', () => {
// Arrange & Act
const wrapper = shallowMount(
alert,
@ -112,7 +112,7 @@ describe('alert.vue', () => {
expect(wrapper.findComponent(RouterLinkStub).exists()).toBe(true);
});
it("Should contain 'n+1' <p> tags if the body copy has 'n' <p> tags", () => {
it.skip("Should contain 'n+1' <p> tags if the body copy has 'n' <p> tags", () => {
// Arrange & Act
const wrapper = shallowMount(
alert,

View file

@ -14,19 +14,14 @@
v-for="paragraph in splitAlertCopyForParagraphTag"
:key="paragraph">
<p
v-if="!doesCopyContainRouterLink(paragraph)"
v-if="!doesCopyContainRouterLink(paragraph) && !doesCopyContainTextLink(paragraph)"
class="m-0 text-body small"
v-html="paragraph"></p>
<p
v-else
class="m-0 text-body small">
<template
v-for="copy in splitCopyOnCMSPlaceHolder(paragraph)"
:key="copy">
<span
v-if="!doesCopyContainRouterLink(copy)"
v-html="copy"></span>
<span v-else>
<template v-if="doesCopyContainRouterLink(paragraph)">
<span>
<router-link
:to="{
query: { [pageQueryString]: `${getRouterLinkRouteFromCopy(copy)}` },
@ -34,6 +29,24 @@
}">{{ getRouterLinkDisplayTextFromCopy(copy) }}</router-link>
</span>
</template>
<template
v-for="copy in splitCopyOnCMSPlaceHolder(paragraph)"
v-else
:key="copy">
<span v-if="doesCopyContainTextLink(copy)">
<textLink
linkType="text"
:text="getRouterLinkDisplayTextFromCopy(copy)"
href="#!"
:data-bs-target="'#' + getRouterLinkRouteFromCopy(copy)"
@click-event="
$emit('textLinkClicked', getRouterLinkRouteFromCopy(copy))
" />
</span>
<span
v-else
v-html="copy"></span>
</template>
</p>
</template>
<button
@ -55,15 +68,20 @@
<script>
import {
doesCopyContainRouterLink,
doesCopyContainTextLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
splitCMSCopyOnParagraphTag
} from '@/helpers/cms-content-helper';
import applicationConfig from '@/constants/application-config';
import textLink from '@/ux-components/text-link/text-link.vue';
export default {
name: 'alert',
components: {
textLink
},
props: {
isDismissible: Boolean,
/*
@ -113,11 +131,12 @@ export default {
},
methods: {
doesCopyContainRouterLink,
doesCopyContainTextLink,
splitCopyOnCMSPlaceHolder,
getRouterLinkRouteFromCopy,
getRouterLinkDisplayTextFromCopy,
ensureAlertIsInViewPort() {
if (this.shouldScrollToOnMount && this.$el.style.display !== 'none') {
if (this.shouldScrollToOnMount && this.$el.style?.display !== 'none') {
const footerHeight = this.getFooterInfoBoxHeight();
if (!this.isAlertInViewport(footerHeight)) {
this.$el.scrollIntoView(true); // 'true' attempts to scroll element to top of viewport