INSR-8489: Update cart dropdown to add tooltip for advanced mobile fee

Also includes a modal when trying to remove the item from the cart.
This commit is contained in:
Alex Humphries 2026-05-21 15:47:19 -04:00
parent 76ebbf335a
commit f46e584d6b
8 changed files with 517 additions and 13 deletions

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 0C6.41775 0 4.87104 0.469192 3.55544 1.34824C2.23985 2.22729 1.21447 3.47672 0.608967 4.93853C0.00346626 6.40034 -0.15496 8.00887 0.153721 9.56072C0.462403 11.1126 1.22433 12.538 2.34315 13.6569C3.46197 14.7757 4.88743 15.5376 6.43928 15.8463C7.99113 16.155 9.59966 15.9965 11.0615 15.391C12.5233 14.7855 13.7727 13.7602 14.6518 12.4446C15.5308 11.129 16 9.58225 16 8C16 5.87827 15.1571 3.84344 13.6569 2.34315C12.1566 0.842855 10.1217 0 8 0V0ZM8.4672 11.4C8.4672 11.5485 8.4082 11.691 8.30318 11.796C8.19816 11.901 8.05572 11.96 7.9072 11.96C7.75868 11.96 7.61624 11.901 7.51122 11.796C7.4062 11.691 7.3472 11.5485 7.3472 11.4V7.08C7.3472 6.93148 7.4062 6.78904 7.51122 6.68402C7.61624 6.579 7.75868 6.52 7.9072 6.52C8.05572 6.52 8.19816 6.579 8.30318 6.68402C8.4082 6.78904 8.4672 6.93148 8.4672 7.08V11.4ZM7.9072 5.4C7.79645 5.4 7.68818 5.36716 7.59608 5.30562C7.50399 5.24409 7.43222 5.15663 7.38983 5.0543C7.34745 4.95198 7.33636 4.83938 7.35796 4.73075C7.37957 4.62212 7.43291 4.52234 7.51122 4.44402C7.58954 4.3657 7.68932 4.31237 7.79795 4.29076C7.90658 4.26915 8.01918 4.28024 8.12151 4.32263C8.22383 4.36501 8.31129 4.43679 8.37283 4.52888C8.43436 4.62097 8.4672 4.72924 8.4672 4.84C8.46741 4.9136 8.45307 4.98651 8.42501 5.05455C8.39694 5.12259 8.3557 5.18441 8.30365 5.23645C8.25161 5.28849 8.18979 5.32973 8.12176 5.3578C8.05372 5.38587 7.9808 5.40021 7.9072 5.4Z" fill="#0070D1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -2,6 +2,7 @@
exports[`cart-dropdown component initial data rendered as expected 1`] = `
Object {
"ADVANCED_MOBILE_MODAL_REF_NAME": "advancedMobileModal",
"cartItemType": Object {
"MOBILE_FEE": "MOBILE FEE",
"RECYCLE_FEE": "RECYCLE FEE",
@ -9,6 +10,8 @@ Object {
"WARRANTY": "WARRANTY",
},
"widget": Object {
"advancedMobileModal": "AdvancedMobileModal",
"advancedMobileTooltip": "AdvancedMobileTooltip",
"amountDue": "AmountDueTextWidget",
"amountPaid": "AmountPaidTextWidget",
"basePrice": "BasePriceWidget",

View file

@ -0,0 +1,177 @@
<template>
<div class="advanced-mobile-modal-container">
<modal
ref="advancedMobileModal"
:onModalClosedCallback="onModalClosed">
<div>
<div class="header">{{ modalHeaderText }}</div>
<div class="subheader">{{ modalHeaderSubText }}</div>
</div>
<div>
<div class="message"
v-html="modalBodyText">
</div>
</div>
<div class="footer-container">
<div>
<button type="button" aria-live="polite" aria-label="Confirm Appointment" class="btn btn-danger change-appointment"
@click="changeAppointmentClicked">
{{ modalCloseButtonText }}
</button>
</div>
<div>
<button type="button" aria-live="polite" aria-label="See more options" class="btn btn-link cancel"
@click="footerButtonClick">
{{ modalFooterSubText }}
</button>
</div>
</div>
</modal>
</div>
</template>
<script>
import modal from '@/digital-components/modal/modal.vue';
export default {
name: 'advanced-mobile-modal',
components: {
modal
},
emits: ['change-appointment-clicked'],
props: {
cmsWidgetName: {
type: String,
default: 'SuggestTimeslotModal'
}
},
computed: {
modalBodyText() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText');
},
modalCloseButtonText() {
return this.getCmsContent(this.cmsWidgetName, 'FooterText');
},
modalFooterSubText() {
return this.getCmsContent(this.cmsWidgetName, 'BodyText2');
},
modalHeaderSubText() {
return this.getCmsContent(this.cmsWidgetName, 'SubheaderText');
},
modalHeaderText() {
return this.getCmsContent(this.cmsWidgetName, 'HeaderText');
},
},
methods: {
openModal() {
this.$refs.advancedMobileModal.openModal();
},
footerButtonClick() {
this.$refs.advancedMobileModal.closeModal();
},
changeAppointmentClicked() {
this.$emit('change-appointment-clicked');
this.$refs.advancedMobileModal.closeModal();
},
}
};
</script>
<style lang="scss" scoped>
.advanced-mobile-modal-container {
.modal {
padding-right: 0;
}
:deep(.modal) {
.modal-dialog {
@include media-breakpoint-up(xs) {
width: auto;
max-width: unset;
}
@include media-breakpoint-up(xl) {
width: 37.5rem;
max-width: 37.5rem;
}
.modal-content {
width: 22rem;
max-width: 22rem;
padding: 1.5rem 0;
border-radius: 0.5rem;
line-height: 1.5rem;
.modal-header {
padding: 0;
}
.modal-body {
padding: 0 1rem;
}
.modal-footer {
display: none;
}
.header {
margin-top: 1rem;
color: #db0020;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.03em;
font-weight: 600;
font-size: 0.875rem;
}
.subheader {
color: black;
text-align: center;
letter-spacing: 0.03em;
font-weight: 400;
font-size: 1.25rem;
line-height: 2rem;
}
.message {
margin-top: 1.5rem;
color: #525656;
letter-spacing: 0.03em;
font-weight: 400;
font-size: 1rem;
line-height: 1.625rem;
.emphasize {
color: black;
font-weight: 600;
}
}
.footer-container {
margin-top: 1.5rem;
padding: 0 1.5rem;
border: none;
.change-appointment {
width: 100%;
padding: .75rem 0;
border-radius: 4.5rem;
letter-spacing: 0.03em;
font-weight: 600;
font-size: 1rem;
}
.cancel {
text-align: center;
width: 100%;
margin-top: 1.5rem;
letter-spacing: 0.03em;
font-weight: 600;
font-size: 1rem;
line-height: 1.625rem;
}
}
}
}
}
}
</style>

View file

@ -33,7 +33,13 @@
:key="i"
class="cart-item packaged">
<div class="price-row">
<span>{{ item?.name ?? '' }}</span>
<span>
{{ item?.name ?? '' }}
<toolTip
v-if="item.cartItemType === cartItemType.MOBILE_FEE"
:cmsWidgetName="widget.advancedMobileTooltip"
:customValueMap="advancedMobileTooltipCustomValueMap" />
</span>
<span>{{ formatAmountInDollars(item?.subTotal ?? 0) }}</span>
</div>
<textLink
@ -47,6 +53,17 @@
<span class="sr-only">{{ item?.name ?? '' }}</span>
</template>
</textLink>
<textLink
v-if="item.cartItemType === cartItemType.MOBILE_FEE
&& !readOnly"
linkType="text"
text="Remove"
href="javascript:void(0)"
@clickEvent="openModal(ADVANCED_MOBILE_MODAL_REF_NAME)">
<template #after-text>
<span class="sr-only">{{ item?.name ?? '' }}</span>
</template>
</textLink>
</div>
</div>
<div
@ -99,6 +116,10 @@
<span class="deductible-label">{{ deductibleLabel }}:</span>
<span class="deductible-value">{{ getDisplayed(deductible) }}</span>
</div>
<advancedMobileModal
:ref="ADVANCED_MOBILE_MODAL_REF_NAME"
:cmsWidgetName="widget.advancedMobileModal"
@changeAppointmentClicked="switchToInShop" />
</template>
<script>
@ -107,6 +128,8 @@ import contentGroupModal from '@/iss-components/content-group-modal/content-grou
import textBlock from '@/digital-components/text-block/text-block.vue';
import textLink from '@/ux-components/text-link/text-link.vue';
import { formatAmountInDollars } from '@/helpers/text-helper.js';
import advancedMobileModal from '@/iss-components/cart-dropdown/advanced-mobile-modal/advanced-mobile-modal.vue';
import toolTip from '@/ux-components/tool-tip/tool-tip.vue';
// Constants
import { experimentSettings } from '@/constants/experiments';
@ -126,17 +149,21 @@ import { getPriceOfLineItem, getPriceOfLineItems, getTaxOfLineItems } from '@/he
import { processIfStatements } from '@/helpers/cms-content-helper';
const VERIFYING_COVERAGE = 'Verifying coverage';
const ADVANCED_MOBILE_MODAL_REF_NAME = 'advancedMobileModal';
export default {
name: 'confirmation-cart',
components: {
textLink
textLink,
advancedMobileModal,
toolTip
},
props: {
readOnly: Boolean,
showAsPaid: Boolean,
submittedOrder: Object
},
emits: ['switch-to-in-shop'],
data() {
return {
widget: {
@ -153,9 +180,12 @@ export default {
vapsItemDescriptions: 'VapsItemDescriptions',
warrantyText: 'WarrantyCartItemTextWidget',
guaranteeText: 'GuaranteeCartItemTextWidget',
total: 'TotalWidget'
total: 'TotalWidget',
advancedMobileTooltip: 'AdvancedMobileTooltip',
advancedMobileModal: 'AdvancedMobileModal'
},
cartItemType
cartItemType,
ADVANCED_MOBILE_MODAL_REF_NAME
};
},
computed: {
@ -237,9 +267,6 @@ export default {
if (this.recycleFeeCartItem && !isRecycleFeeHidden) {
items.push(this.recycleFeeCartItem);
}
if (this.mobileFeeCartItem && !isMobileFeeHidden && !this.mobileFeeCartItem.isInsurable) {
items.push(this.mobileFeeCartItem);
}
const vapPartTypesInOrder = Array.from(new Set(this.lineItems.vaps?.map((vap) => vap.partType) ?? []));
const vapsCartItems = vapPartTypesInOrder.map((partType) => {
switch (partType) {
@ -254,6 +281,9 @@ export default {
}
});
items.push(...vapsCartItems.filter((item) => item != null));
if (this.mobileFeeCartItem && !isMobileFeeHidden && !this.mobileFeeCartItem.isInsurable) {
items.push(this.mobileFeeCartItem);
}
return items;
},
frontWipersCartItem() {
@ -308,7 +338,7 @@ export default {
const mobileFeeLineItem = getMobileFeeLineItem(this.cartOrder);
const cartItem = mobileFeeLineItem
? this.getCartItem(
this.getCmsContent(this.widget.mobileFee, widgetFields.TEXT_BLOCK_WIDGET.TEXT),
mobileFeeLineItem.displayName ?? this.getCmsContent(this.widget.mobileFee, widgetFields.TEXT_BLOCK_WIDGET.TEXT),
[mobileFeeLineItem],
cartItemType.MOBILE_FEE,
partTypeStrings.MOBILE_FEE
@ -335,6 +365,11 @@ export default {
return this.cartOrder.lineItems.glassParts?.some((glassPart) => {
return glassPart.childParts?.some((childPart) => childPart.partType === partTypeStrings.MOLDING);
});
},
advancedMobileTooltipCustomValueMap() {
return {
feeAmount: this.mobileFeeCartItem ? formatAmountInDollars(this.mobileFeeCartItem.subTotal) : ''
}
}
},
methods: {
@ -402,6 +437,9 @@ export default {
return null;
}
},
switchToInShop() {
this.$emit('switch-to-in-shop');
},
formatAmountInDollars
}
};
@ -418,7 +456,7 @@ export default {
.cart-item {
display: flex;
flex-direction: column;
padding: .5rem 0rem;
padding: .5rem 1rem;
width: 100%;
min-width: fit-content;
&:nth-child(odd) {
@ -431,12 +469,14 @@ export default {
.price-row {
display: flex;
justify-content: space-between;
:last-child {
margin-left: auto;
}
}
span, a {
white-space: nowrap;
padding: 0rem 1rem;
}
a:hover {

View file

@ -50,6 +50,10 @@ const addToCalendarStub = {
render: () => {}
};
const cartDropdownStub = {
template: '<div></div>'
};
const initialStore = {
order: {
schedule: {
@ -171,7 +175,8 @@ function getMountedComponent(mainInitialState = {}, initialData = {}, methodToRu
mountOptions.global.stubs = {
siteFooter: footerStub,
siteHeader: headerStub,
addToCalendar: addToCalendarStub
addToCalendar: addToCalendarStub,
cartDropdown: cartDropdownStub
};
const testingPinia = createTestingPinia({

View file

@ -34,7 +34,8 @@
:showAsPaid="false"
:readOnly="false"
recyclingModalCmsWidgetName="RecycleModal"
servicePackageTitleWidgetName="ServicePackageTitle" />
servicePackageTitleWidgetName="ServicePackageTitle"
@switchToInShop="handleSwitchToInShop" />
</div>
<alert
v-if="displayPayInAdvanceAlert"
@ -500,6 +501,14 @@ export default {
break;
}
},
handleSwitchToInShop() {
this.mainStore.resetServiceLocationAndDependencies();
this.mainStore.updateAppointmentType(AppointmentTypeStrings.IN_SHOP);
this.$router.navigateWithSpinner(
this.navigationScenarios.EDIT_SCHEDULE,
this.$route
);
},
openContactDetailsModal() {
this.$refs.contactDetailsDrawer.openModal();
},

View file

@ -0,0 +1,121 @@
import { shallowMount, mount } from "@vue/test-utils";
import toolTip from "@/ux-components/tool-tip/tool-tip.vue";
const mockMixin = {
methods: {
getCmsContent: jest.fn(() => "Mock CMS Content"),
},
};
function setupMocks(mountOptionsMockData = {}) {
const defaultMountOptions = {
propsData: {
cmsWidgetName: "testWidget",
},
mixins: [mockMixin],
};
return { ...defaultMountOptions, ...mountOptionsMockData };
}
describe("toolTip.vue", () => {
afterEach(() => {
jest.clearAllMocks();
});
it("should not show tooltip initially", () => {
// Arrange
const wrapper = shallowMount(toolTip, setupMocks());
// Assert
expect(wrapper.vm.isVisible).toBe(false);
expect(wrapper.find(".tooltip").exists()).toBe(false);
});
it("should show tooltip when icon is clicked", async () => {
// Arrange
const wrapper = shallowMount(toolTip, setupMocks());
const icon = wrapper.find(".tooltip-icon");
// Act
await icon.trigger("click");
// Assert
expect(wrapper.vm.isVisible).toBe(true);
expect(wrapper.find(".tooltip").exists()).toBe(true);
});
it("should hide tooltip when clicked outside", async () => {
// Arrange
const wrapper = shallowMount(toolTip, setupMocks());
const icon = wrapper.find(".tooltip-icon");
// Show tooltip first
await icon.trigger("click");
expect(wrapper.vm.isVisible).toBe(true);
// Act - simulate click outside
const clickEvent = new Event("click");
Object.defineProperty(clickEvent, "target", { value: document.body });
document.dispatchEvent(clickEvent);
// Wait for next tick to allow event handling
await wrapper.vm.$nextTick();
// Assert
expect(wrapper.vm.isVisible).toBe(false);
expect(wrapper.find(".tooltip").exists()).toBe(false);
});
it("should toggle tooltip visibility when icon is clicked multiple times", async () => {
// Arrange
const wrapper = shallowMount(toolTip, setupMocks());
const icon = wrapper.find(".tooltip-icon");
// Act & Assert - First click shows
await icon.trigger("click");
expect(wrapper.vm.isVisible).toBe(true);
// Second click hides
await icon.trigger("click");
expect(wrapper.vm.isVisible).toBe(false);
// Third click shows again
await icon.trigger("click");
expect(wrapper.vm.isVisible).toBe(true);
});
it("should not hide tooltip when clicking inside the tooltip container", async () => {
// Arrange
const wrapper = shallowMount(toolTip, setupMocks());
const icon = wrapper.find(".tooltip-icon");
// Show tooltip first
await icon.trigger("click");
expect(wrapper.vm.isVisible).toBe(true);
// Act - simulate click inside container
const container = wrapper.find(".tooltip-container").element;
const clickEvent = new Event("click");
Object.defineProperty(clickEvent, "target", { value: container });
document.dispatchEvent(clickEvent);
// Wait for next tick
await wrapper.vm.$nextTick();
// Assert - tooltip should still be visible
expect(wrapper.vm.isVisible).toBe(true);
});
it("should calculate tooltipHorizontalOffset based on parent element width", () => {
// Arrange
const wrapper = mount(toolTip, setupMocks());
const parentDiv = document.createElement("div");
Object.defineProperty(parentDiv, "offsetWidth", { value: 300, writable: true });
const container = wrapper.vm.$refs.container;
Object.defineProperty(container, "parentElement", { value: parentDiv, writable: true });
// Act & Assert
expect(wrapper.vm.tooltipHorizontalOffset).toBe(-290);
});
});

View file

@ -0,0 +1,144 @@
<template>
<div class="tooltip-container" ref="container">
<span @click="toggleTooltip" class="tooltip-icon">
<slot name="icon">
<img src="@/assets/img/icons/info-circle-blue.svg" />
</slot>
</span>
<span
v-if="isVisible"
class="tooltip"
:class="{ 'tooltip-visible': isVisible }"
:style="{ left: tooltipHorizontalOffset + 'px', top: '1rem' }">
<slot>
<h3 v-html="toolTipHeaderText"></h3>
<p v-html="toolTipBodyText"></p>
</slot>
</span>
</div>
</template>
<script>
import { getStringWithCustomValues } from '@/helpers/cms-content-helper';
export default {
name: "toolTip",
props: {
cmsWidgetName: {
type: String,
default(rawProps) {
if (!rawProps.cmsWidgetName) {
console.log("Error: Missing a CMS Widget Name (required field)");
}
return "widgetUndefined";
},
},
customValueMap: {
type: Object,
default: () => ({}),
}
},
data() {
return {
isVisible: false,
};
},
mounted() {
document.addEventListener("click", this.handleClickOutside);
},
unmounted() {
document.removeEventListener("click", this.handleClickOutside);
},
computed: {
toolTipHeaderText() {
return getStringWithCustomValues(
this.getCmsContent(this.cmsWidgetName, "HeaderText") || "",
this.customValueMap
);
},
toolTipBodyText() {
return getStringWithCustomValues(
this.getCmsContent(this.cmsWidgetName, "BodyText") || "",
this.customValueMap
);
},
tooltipHorizontalOffset() {
// Get the width of the parent container - used to position the tooltip to the left
if (this.$refs.container && this.$refs.container.parentElement) {
return 10 - this.$refs.container.parentElement.offsetWidth;
}
return 0;
}
},
methods: {
showTooltip() {
this.isVisible = true;
},
hideTooltip() {
this.isVisible = false;
},
toggleTooltip() {
this.isVisible = !this.isVisible;
},
handleClickOutside(event) {
if (this.$refs.container && !this.$refs.container.contains(event.target)) {
this.hideTooltip();
}
},
},
};
</script>
<style lang="scss" scoped>
.tooltip-container {
position: relative;
display: inline-block;
margin-left: 0.5rem;
padding: 0rem;
.tooltip-icon {
cursor: pointer;
display: inline-block;
img {
width: 1rem;
}
}
.tooltip {
display: flex;
flex-direction: column;
position: absolute;
top: 100%;
left: 1rem;
padding: 1rem;
border-radius: $border-radius;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s,
visibility 0.2s;
z-index: 500;
width: 18.75rem;
background-color: $white;
box-shadow: $box-shadow;
h3 {
margin-bottom: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: $black;
}
p {
margin: 0;
font-size: .875rem;
line-height: 1.5rem;
}
}
.tooltip-visible {
opacity: 1;
visibility: visible;
}
}
</style>