Merge pull request #90 from Safelite/feature/SSR-188

SSR-188
This commit is contained in:
Johan Gunawan 2023-01-03 10:44:02 -05:00 committed by GitHub
commit f825cea0f0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 502 additions and 19 deletions

View file

@ -0,0 +1,8 @@
const damageCustomLabels = Object.freeze({
MATCH: 'match',
REAR_WINDOW: 'rear window',
SIDE_WINDOW: 'side window',
WINDSHIELD: 'windshield',
});
export default damageCustomLabels;

View file

@ -55,6 +55,10 @@ const endpoints = {
url: "/analytics/api/v1/analytics/log-custom-event",
method: "POST",
},
LookupVehicleByVin: {
url: "/vehicle/api/v1/vehicle/lookup",
method: "POST",
},
InitializeSession: {
url: "/analytics/api/v1/analytics/initialize",
method: "POST",

View file

@ -0,0 +1,6 @@
const vehicleLookupAlertTypes = Object.freeze({
NOT_FOUND: 'notFound',
NOT_MATCHED: 'notMatched',
});
export default vehicleLookupAlertTypes;

View file

@ -0,0 +1,89 @@
import damageCustomLabels from '@/constants/damage-custom-labels';
import { damageLocationsSelected } from '@/constants/damage-locations-selected';
import { useMainStore } from '@/store';
export function getDamageString() {
const mainStore = useMainStore();
// If it's a repair it's always a windshield.
const { isRepair } = mainStore.damage;
if (isRepair) {
return damageCustomLabels.WINDSHIELD;
}
const damageLocations = mainStore.damage.glassToReplace;
if (!damageLocations || !Array.isArray(damageLocations)) {
return '';
}
if (damageLocations.length > 1) {
return damageCustomLabels.MATCH;
}
const { glassLocation } = damageLocations[0];
if (glassLocation) {
switch (glassLocation) {
case damageLocationsSelected.WINDSHIELD:
return damageCustomLabels.WINDSHIELD;
case damageLocationsSelected.DRIVER:
case damageLocationsSelected.PASSENGER:
return damageCustomLabels.SIDE_WINDOW;
case damageLocationsSelected.REAR:
return damageCustomLabels.REAR_WINDOW;
default:
return '';
}
}
// No condition is matched
return '';
}
/**
* Commented code are copied directly from DigitalConsumer.FixMyGlass
* and have not been adjusted for ISS.
*/
// import store from "@/store";
// import baseMixin from "@/mixins/base-mixin.js";
// import { storeActions } from "@/constants/store-actions";
// export function getIsWindshieldOnly() {
// const damageLocations = store.getters.damage.glassToReplace;
// const returnString =
// damageLocations &&
// damageLocations.length === 1 &&
// damageLocations[0]?.glassLocation === "Windshield"
// ? "windshield"
// : "glass";
// return returnString;
// }
// export async function isGlassAvailableForCarId(carId) {
// const newGlassOptions = await baseMixin.methods.dispatchStoreAction(
// storeActions.GET_DAMAGE_OPTIONS,
// { carId: carId }
// );
// const currentGlassOptions = store.getters.damage.glassToReplace;
// const optionsMap = {
// Windshield: "windshieldOptions",
// Driver: "driverSideOptions",
// Passenger: "passengerSideOptions",
// Rear: "backGlassOptions",
// };
// for (const option of currentGlassOptions) {
// if (
// !newGlassOptions.data[
// optionsMap[option.glassLocation]
// ].availableReplacementOptions.includes(option.glassName)
// ) {
// return false;
// }
// }
// return true;
// }

View file

@ -0,0 +1,113 @@
<template>
<div class="vin-location-information">
<div
class="vin-toggle mt-2"
:class="[isVinLocationDetailVisible ? 'active' : '']"
@click="handleClickToggle"
>
<TextLink
link-type="text"
href="#!"
:text="textForToggle"
/>
</div>
<Transition>
<div
v-show="isVinLocationDetailVisible"
id="vin-location-detail-wrapper"
class="mt-2"
>
<div
v-html="vinLocationDetailContent"
id="vin-location-detail-content"
class="mb-2"
/>
<img
:src="vinLocationDetailImage"
alt="VIN Locations"
>
</div>
</Transition>
</div>
</template>
<script>
import TextLink from '@/ux-components/text-link/text-link.vue';
export default {
name: 'vin-location-information',
components: {
TextLink,
},
data() {
return {
isVinLocationDetailVisible: false,
};
},
computed: {
textForToggle() {
return this.getCmsContent('WhereCanIFindMyVINToggle', 'HeaderText');
},
vinLocationDetailContent() {
return this.getCmsContent('WhereCanIFindMyVINToggle', 'BodyText');
},
vinLocationDetailImage() {
return this.getCmsContent('WhereCanIFindMyVINToggle', 'Image');
},
},
methods: {
handleClickToggle() {
this.isVinLocationDetailVisible = !this.isVinLocationDetailVisible;
},
},
};
</script>
<style lang="scss">
.vin-location-information {
.vin-toggle {
&::after {
content: "";
transition: all 0.5s ease;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 8.9' xml:space='preserve'%3e%3cpath d='M8 8.9c-.2 0-.5-.1-.6-.3L.3 1.5C.1 1.4 0 1.1 0 .9 0 .7.1.4.3.3.4.1.7 0 .9 0c.2 0 .5.1.6.3L8 6.7 14.5.2c.1-.1.4-.2.6-.2.2 0 .5.1.6.3s.3.4.3.6c0 .2-.1.5-.3.6L8.6 8.6c-.1.2-.4.3-.6.3z' fill='%231474a2'/%3e%3c/svg%3e");
background-repeat: no-repeat;
margin-left: 0.5rem;
width: 16px;
height: 9px;
display: inline-block;
}
&.active::after {
transform: rotate(180deg);
}
a {
font-size: 0.875rem;
}
}
}
#vin-location-detail-wrapper {
// START - Vue Transition
&.v-enter-active,
&.v-leave-active {
transition: opacity 250ms ease-in;
}
&.v-enter-from,
&.v-leave-to {
opacity: 0;
}
// END - Vue Transition
img {
width: 100%;
}
}
#vin-location-detail-content {
font-size: 0.875rem;
p {
font-size: 0.875rem;
margin-bottom: 0;
}
ol {
margin-top: 0.5rem;
margin-bottom: 0;
}
}
</style>

View file

@ -0,0 +1,25 @@
<template>
<Alert
alert-class="alert-danger"
cms-widget-name="AlertVinNotFoundWidget"
id="vehicle-not-found-alert"
/>
</template>
<script>
import Alert from '@/ux-components/alert/alert.vue';
export default {
name: 'vehicle-not-found-alert',
components: {
Alert,
},
};
</script>
<style>
/**
Override wrong margin-bottom rule in the Alert component.
*/
#vehicle-not-found-alert p:last-of-type {
margin-bottom: 0 !important;
}
</style>

View file

@ -0,0 +1,48 @@
<template>
<Alert
alert-class="alert-warning"
cms-widget-name="AlertMatchedDifferentVehicleWidget"
:manual-copy="body"
:manual-headline="header"
/>
</template>
<script>
import { getDamageString } from '@/helpers/damage-helper';
import Alert from '@/ux-components/alert/alert.vue';
export default {
name: 'vehicle-not-matched-alert',
components: {
Alert,
},
inject: ['vehicleFromLookup'],
computed: {
header() {
return (
this.getCmsContent('AlertMatchedDifferentVehicleWidget', 'HeadlineText')
.replaceAll('{custom:damage}', getDamageString())
);
},
body() {
let year = '';
let make = '';
let model = '';
if (this.vehicleFromLookup) {
year = this.vehicleFromLookup.year;
make = this.vehicleFromLookup.make;
model = this.vehicleFromLookup.model;
}
return (
this.getCmsContent('AlertMatchedDifferentVehicleWidget', 'BodyText')
.replaceAll('{custom:damage}', getDamageString())
.replaceAll('{custom:vinlookupYear}', year)
.replaceAll('{custom:vinlookupMake}', make)
.replaceAll('{custom:vinlookupModel}', model)
);
},
},
};
</script>

View file

@ -0,0 +1,49 @@
<template>
<div
id="vin-lookup-alerts-wrapper"
:class="wrapperCssClass"
>
<VehicleNotFoundAlert
v-if="isVehicleNotFoundVisible"
/>
<VehicleNotMatchedAlert
v-else-if="isVehicleNotMatchedVisible"
/>
</div>
</template>
<script>
import vehicleLookupAlertTypes from '@/constants/vehicle-lookup-alert-types';
import VehicleNotFoundAlert from './vehicle-not-found-alert/vehicle-not-found-alert.vue';
import VehicleNotMatchedAlert from './vehicle-not-matched-alert/vehicle-not-matched-alert.vue';
export default {
name: 'vin-lookup-alerts',
components: {
VehicleNotFoundAlert,
VehicleNotMatchedAlert,
},
props: {
activeAlertType: {
type: String,
validator(value) {
const acceptedValues = [null];
acceptedValues.push(...Object.values(vehicleLookupAlertTypes));
return acceptedValues.includes(value);
},
},
},
computed: {
isVehicleNotFoundVisible() {
return this.activeAlertType === vehicleLookupAlertTypes.NOT_FOUND;
},
isVehicleNotMatchedVisible() {
return this.activeAlertType === vehicleLookupAlertTypes.NOT_MATCHED;
},
wrapperCssClass() {
return {
'mb-5': this.activeAlertType !== null,
};
},
},
};
</script>

View file

@ -1,5 +1,9 @@
<template>
<VeeValidateForm @submit="onSubmit" @invalid-submit="onInvalidSubmit">
<template>
<VeeValidateForm
@submit="onSubmit"
@invalid-submit="onInvalidSubmit"
v-slot="{ meta }"
>
<div class="page-container-grouped-styles overflow-auto">
<SiteHeader
cms-widget-name="SiteHeaderWidget"
@ -11,13 +15,23 @@
<SiteSubHeader
cms-widget-name="SiteSubHeaderWidget"
/>
<div class="fade-on-route-transition sub-container make-tall">
<p>VIN Lookup Page Placeholder</p>
<div class="fade-on-route-transition sub-container make-tall mt-5">
<VinLookupAlerts
:active-alert-type="activeVehicleLookupAlertType"
/>
<VinQuestion
v-model="vin"
/>
<VinLocationInformation />
<SiteFooter
cms-widget-name="SiteFooterWidget"
:is-forward-action-disabled="isForwardActionDisabled"
:is-forward-action-disabled="!meta.valid"
@back-clicked="backButtonAction"
@forward-clicked="forwardButtonAction"
ref="siteFooter"
/>
</div>
</div>
@ -25,29 +39,54 @@
</template>
<script>
// Import Supporting Files
import { computed } from 'vue';
import { endpoints } from '@/constants/endpoints';
import vehicleLookupAlertTypes from '@/constants/vehicle-lookup-alert-types';
import globalMethods from '@/global-methods';
import { fetchCmsContentForPage } from '@/helpers/cms-content-helper';
import { settleAllPromises } from '@/helpers/layout-helper';
import { Form } from 'vee-validate';
import BaseFormMixin from '@/mixins/base-form-mixin.js';
import { useMainStore } from '@/store';
// Import Component
import BaseFormMixin from '@/mixins/base-form-mixin';
import { Form } from 'vee-validate';
import SiteFooter from '@/common-components/site-footer/site-footer.vue';
import SiteHeader from '@/common-components/site-header/site-header.vue';
import SiteSubHeader from '@/common-components/site-sub-header/site-sub-header.vue';
import VehicleBanner from '@/common-components/vehicle-banner/vehicle-banner.vue';
import VinLocationInformation from './vin-location-information/vin-location-information.vue';
import VinLookupAlerts from './vin-lookup-alerts/vin-lookup-alerts.vue';
import VinQuestion from './vin-question/vin-question.vue';
export default {
name: 'vin-lookup',
mixins: [BaseFormMixin],
components: {
VeeValidateForm: Form,
SiteFooter,
SiteHeader,
SiteSubHeader,
VehicleBanner,
VeeValidateForm: Form,
VehicleBanner,
VinLocationInformation,
VinLookupAlerts,
VinQuestion,
},
setup() {
const mainStore = useMainStore();
return { mainStore };
},
data() {
return {};
return {
activeVehicleLookupAlertType: null,
vehicleFromLookup: null,
vin: null,
};
},
provide() {
return {
vehicleFromLookup: computed(() => this.vehicleFromLookup),
};
},
async beforeRouteEnter(to, from, next) {
const cmsContentPromise = fetchCmsContentForPage(to.query.issPage);
@ -63,16 +102,11 @@ export default {
const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => {
vm.setCmsContent(resultMap.cmsContent);
vm.setCmsContent(resultMap.cmsContent);
});
},
computed: {
isForwardActionDisabled() {
return true;
}
},
methods: {
arePagePrerequisiteValid() {
arePagePrerequisiteValid() {
return true;
},
backButtonAction() {
@ -81,8 +115,55 @@ export default {
*/
this.$router.navigate(this.navigationScenarios.CLICKED_BACK, this.$route);
},
forwardButtonAction() {
return true;
async forwardButtonAction() {
this.resetActiveAlert();
// NOTE: If form is not valid, this method is not called when 'Continue' button is clicked
const vehicleLookupResponse = await this.lookupVehicleByVin(this.vin);
if (vehicleLookupResponse.error) {
this.activeVehicleLookupAlertType = vehicleLookupAlertTypes.NOT_FOUND;
this.resetVehicleFromLookup();
this.$refs.siteFooter.removeLoader();
return;
}
this.vehicleFromLookup = vehicleLookupResponse.data;
if (this.vehicleFromLookup.carId !== this.mainStore.vehicle.carId) {
this.activeVehicleLookupAlertType = vehicleLookupAlertTypes.NOT_MATCHED;
const vehicleYearMakeModel = `${this.vehicleFromLookup.year} ${this.vehicleFromLookup.make} ${this.vehicleFromLookup.model}`;
this.$refs.siteFooter.updateButtonText(`Continue with ${vehicleYearMakeModel}`);
this.$refs.siteFooter.removeLoader();
}
// Continue
},
async lookupVehicleByVin(vin) {
try {
const response = await globalMethods.callHttpClient({
method: endpoints.LookupVehicleByVin.method,
endpoint: endpoints.LookupVehicleByVin.url,
payload: {
vin,
},
});
return response;
} catch (responseError) {
return {
error: {
status: responseError.status,
},
};
}
},
resetActiveAlert() {
this.activeVehicleLookupAlertType = null;
},
resetVehicleFromLookup() {
this.vehicleFromLookup = null;
},
resetDependentState() {},
},

View file

@ -0,0 +1,44 @@
<template>
<TextboxQuestion
v-model="vin"
cms-widget-name="VinNumberQuestionWidget"
disable-auto-fill
input-id="vin-question"
is-required
max-length="17"
validation-rules="vin-required|vin-format"
/>
</template>
<script>
// Import Other Supporting File(s)
import { defineRule } from 'vee-validate';
import { regex, required } from '@/helpers/validation-rules';
import { errorMessages } from '@/constants/error-messages';
// Import Component(s)
import TextboxQuestion from '@/common-components/textbox-question/textbox-question.vue';
defineRule('vin-required', required(errorMessages.VIN_REQUIRED));
defineRule('vin-format', regex(/^[a-hA-Hj-nJ-NpPr-zR-Z0-9]{17}$/, errorMessages.VIN_FORMAT));
export default {
name: 'vin-question',
props: {
modelValue: String,
},
emits: ['update:modelValue'],
components: {
TextboxQuestion,
},
computed: {
vin: {
get() {
return this.modelValue;
},
set(newValue) {
this.$emit('update:modelValue', newValue);
},
},
},
};
</script>

View file

@ -14,6 +14,13 @@ import experimentMixin from "@/mixins/experiment-mixin.js";
// Vue App Setup
const vueApp = createApp(App);
/**
* Needed to make injections reactively linked to the provider.
* This is not needed once Vue.js is in version 3.3
* https://vuejs.org/guide/components/provide-inject.html#working-with-reactivity
*/
vueApp.config.unwrapInjectedRef = true;
// Pinia
const pinia = createPinia();
vueApp.use(pinia);

View file

@ -94,6 +94,15 @@ const routingTable = function(store) {
},
],
},
{
issPageValue: issPageValues.VIN_LOOKUP,
maps: [
{
scenario: navigationScenarios.CLICKED_BACK,
destinationIssPageValue: issPageValues.VEHICLE_LOOKUP,
},
],
},
{
issPageValue: issPageValues.PART_QUESTIONS,
maps: [