Add in-shop component (with incomplete functionality)

This commit is contained in:
Chloe Herd 2026-06-09 14:24:06 -04:00
parent 0970028fa3
commit 85b0b65ca2
6 changed files with 861 additions and 0 deletions

6
src/assets/img/shop.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.348 12.5171H4.65205C3.64794 12.5171 2.87637 11.5714 3.01647 10.5128L3.33853 8.07548C3.45334 7.20759 4.15097 6.5625 4.97411 6.5625H19.0259C19.849 6.5625 20.5467 7.20759 20.6615 8.07548L20.9835 10.5128C21.1236 11.5714 20.3521 12.5171 19.348 12.5171Z" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.67969 12.5156V18.5656C4.67969 19.5318 5.46294 20.315 6.42814 20.315H17.5697C18.5359 20.315 19.3182 19.5318 19.3182 18.5656V12.5156" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.1249 20.3158V17.7549C14.1249 16.5815 13.1733 15.6289 11.9989 15.6289C10.8255 15.6289 9.87305 16.5815 9.87305 17.7549V20.3158" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.67969 6.55875V5.43301C4.67969 4.46684 5.46294 3.68359 6.42814 3.68359H17.5697C18.5359 3.68359 19.3182 4.46684 19.3182 5.43301V6.55875" stroke="#0A0A0A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,44 @@
import { RouteCodeFlags } from "@/constants/schedule-constants";
import {
getInshopAppointmentTimeSlots,
groupInshopTimeSlotsByTimeOfDay,
mapInshopTimeSlotToDisplaySlot,
} from "./schedule-helper";
describe("schedule-helper inshop time slots", () => {
const dropOffSlot = {
id: `route-${RouteCodeFlags.ALL_DAY_DROP_OFF}`,
startTime: "08:00",
endTime: "17:00",
};
const morningSlot = { id: "route-morning", startTime: "09:30", endTime: "10:00" };
const afternoonSlot = { id: "route-afternoon", startTime: "13:00", endTime: "13:30" };
test("getInshopAppointmentTimeSlots excludes drop off route codes", () => {
const result = getInshopAppointmentTimeSlots([dropOffSlot, morningSlot, afternoonSlot]);
expect(result).toEqual([morningSlot, afternoonSlot]);
});
test("groupInshopTimeSlotsByTimeOfDay splits and sorts slots by start time", () => {
const lateMorning = { id: "route-late-morning", startTime: "11:30", endTime: "12:00" };
const earlyMorning = { id: "route-early-morning", startTime: "08:00", endTime: "08:30" };
const result = groupInshopTimeSlotsByTimeOfDay([
afternoonSlot,
dropOffSlot,
lateMorning,
earlyMorning,
]);
expect(result.morning).toEqual([earlyMorning, lateMorning]);
expect(result.afternoon).toEqual([afternoonSlot]);
});
test("mapInshopTimeSlotToDisplaySlot formats label and route code", () => {
expect(mapInshopTimeSlotToDisplaySlot(morningSlot)).toEqual({
value: "route-morning",
label: "9:30 AM",
routeCodeId: "route-morning",
});
});
});

View file

@ -72,6 +72,50 @@ export function isDropOffRouteCode(routeCode) {
);
}
export const INSHOP_INITIAL_VISIBLE_TIME_SLOTS = 6;
export function getInshopAppointmentTimeSlots(timeSlots = []) {
return (timeSlots ?? []).filter((timeSlot) => !isDropOffRouteCode(timeSlot.id));
}
export function isMorningInshopTimeSlot(timeSlot) {
const hours = parseInt(timeSlot?.startTime?.split(":")[0], 10);
if (Number.isNaN(hours)) {
return false;
}
return hours < 12;
}
function sortTimeSlotsByStartTime(timeSlots) {
return [...timeSlots].sort((a, b) => {
if (a.startTime < b.startTime) return -1;
if (a.startTime > b.startTime) return 1;
return 0;
});
}
export function groupInshopTimeSlotsByTimeOfDay(timeSlots = []) {
const inshopSlots = getInshopAppointmentTimeSlots(timeSlots);
const morning = sortTimeSlotsByStartTime(inshopSlots.filter(isMorningInshopTimeSlot));
const afternoon = sortTimeSlotsByStartTime(
inshopSlots.filter((timeSlot) => !isMorningInshopTimeSlot(timeSlot))
);
return { morning, afternoon };
}
export function formatInshopTimeSlotLabel(timeSlot) {
return militaryToTwelveHourTime(timeSlot.startTime);
}
export function mapInshopTimeSlotToDisplaySlot(timeSlot) {
return {
value: timeSlot.id,
label: formatInshopTimeSlotLabel(timeSlot),
routeCodeId: timeSlot.id,
};
}
export function getTodayDate() {
return new Date();
}

View file

@ -0,0 +1,238 @@
import { shallowMount } from "@vue/test-utils";
import inshopSchedulingCard from "./inshop-scheduling-card";
import { RouteCodeFlags } from "@/constants/schedule-constants";
import { getMountOptions } from "@/helpers/unit-test-helper.js";
const ALL_DAY_DROPOFF_ROUTE_CODE = `03357-001820-I*21346*${RouteCodeFlags.ALL_DAY_DROP_OFF}`;
const OVERNIGHT_DROPOFF_ROUTE_CODE = `03357-001820-I*21346*${RouteCodeFlags.OVERNIGHT_DROP_OFF}`;
const MORNING_INSHOP_ROUTE_CODE = "03357-001820-I*21346*0800";
const AFTERNOON_INSHOP_ROUTE_CODE = "03357-001820-I*21346*1300";
const MOCK_TIME_SLOTS = [
{ id: ALL_DAY_DROPOFF_ROUTE_CODE, startTime: "08:00", endTime: "17:00" },
{ id: OVERNIGHT_DROPOFF_ROUTE_CODE, startTime: "17:00", endTime: "08:00" },
{ id: MORNING_INSHOP_ROUTE_CODE, startTime: "08:00", endTime: "08:30" },
{ id: "03357-001820-I*21346*0830", startTime: "08:30", endTime: "09:00" },
{ id: "03357-001820-I*21346*0900", startTime: "09:00", endTime: "09:30" },
{ id: AFTERNOON_INSHOP_ROUTE_CODE, startTime: "13:00", endTime: "13:30" },
{ id: "03357-001820-I*21346*1400", startTime: "14:00", endTime: "14:30" },
];
const MANY_MORNING_TIME_SLOTS = [
{ id: ALL_DAY_DROPOFF_ROUTE_CODE, startTime: "08:00", endTime: "17:00" },
...["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00"].map((startTime, i) => ({
id: `03357-001820-I*21346*M${i}`,
startTime,
endTime: startTime,
})),
];
const MOCK_PROVIDER = {
providerNumber: "001820",
distanceInMiles: 4.5,
address: {
city: "WORTHINGTON",
state: "OH",
streetAddress: "760 DEARBORN PARK LN",
zipCode: "43085",
},
};
const MOCK_CMS_CONTENT = {
InshopCardWidget: {
BodyText: "Morning:",
BodyText2: "Afternoon:",
FooterText: "View more times",
},
DropoffQuestionWidget: {
QuestionText: "Drop off your vehicle",
Answers: [
{
Name: "AllDayDropOff",
Text: "Drop off before 9:00 am",
SubText:
"Drop off your vehicle before 9:00 AM and pick it up between 45 PM. Park in any open spot and visit the shop's front desk to drop off your key.",
},
{
Name: "OvernightDropOff",
Text: "Overnight drop off",
SubText:
"Drop off your vehicle before closing and pick it up the next business day.",
},
],
},
};
function mountComponent(props = {}, cmsContent = {}) {
const cmsContentByWidget = {
...MOCK_CMS_CONTENT,
...cmsContent,
InshopCardWidget: {
...MOCK_CMS_CONTENT.InshopCardWidget,
...cmsContent.InshopCardWidget,
},
DropoffQuestionWidget: {
...MOCK_CMS_CONTENT.DropoffQuestionWidget,
...cmsContent.DropoffQuestionWidget,
},
};
const cmsMixin = {
methods: {
getCmsContent: jest.fn((widgetName, fieldName) => {
return cmsContentByWidget[widgetName]?.[fieldName] ?? "";
}),
},
};
const mountOptions = getMountOptions({
route: { name: "scheduling" },
mixins: [cmsMixin],
});
return shallowMount(inshopSchedulingCard, {
props: {
provider: MOCK_PROVIDER,
timeSlots: MOCK_TIME_SLOTS,
radioGroupName: "inshopSchedulingTimeSlot-001820",
...props,
},
global: mountOptions.global,
});
}
describe("inshop-scheduling-card.vue", () => {
test("renders city, distance, address, and grouped appointment sections", () => {
const wrapper = mountComponent();
expect(wrapper.text()).toContain("Worthington");
expect(wrapper.text()).toContain("4.5 mi");
expect(wrapper.text()).toContain("760 Dearborn Park Ln, Worthington, OH 43085");
expect(wrapper.text()).toContain("Drop off your vehicle");
expect(wrapper.text()).toContain("Morning:");
expect(wrapper.text()).toContain("Afternoon:");
expect(wrapper.text()).toContain("8:00 AM");
expect(wrapper.text()).toContain("1:00 PM");
expect(wrapper.text()).toContain("7 appts");
wrapper.unmount();
});
test("renders only drop off options that match available time slots", () => {
const wrapper = mountComponent({
timeSlots: [{ id: ALL_DAY_DROPOFF_ROUTE_CODE, startTime: "08:00", endTime: "17:00" }],
});
expect(wrapper.text()).toContain("Drop off before 9:00 am");
expect(wrapper.text()).not.toContain("Overnight drop off");
expect(wrapper.text()).not.toContain("Morning:");
expect(wrapper.text()).toContain("1 appt");
wrapper.unmount();
});
test("hides drop off section when no matching time slots are available", () => {
const wrapper = mountComponent({ timeSlots: [] });
expect(wrapper.text()).not.toContain("Drop off your vehicle");
expect(wrapper.text()).toContain("0 appts");
wrapper.unmount();
});
test("shows drop off SubText only when the option is selected", async () => {
const wrapper = mountComponent();
expect(wrapper.text()).not.toContain("Park in any open spot");
const dropOffInput = wrapper.find(`input[value="${ALL_DAY_DROPOFF_ROUTE_CODE}"]`);
await dropOffInput.setValue(true);
await wrapper.setProps({
modelValue: {
selectedInshopTimeSlot: {
providerNumber: "001820",
routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE,
},
},
});
expect(wrapper.text()).toContain("Park in any open spot");
wrapper.unmount();
});
test("emits update:modelValue with route code when drop off is selected", async () => {
const wrapper = mountComponent();
const dropOffInput = wrapper.find(`input[value="${ALL_DAY_DROPOFF_ROUTE_CODE}"]`);
await dropOffInput.setValue(true);
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
selectedInshopTimeSlot: {
providerNumber: "001820",
routeCodeId: ALL_DAY_DROPOFF_ROUTE_CODE,
},
});
wrapper.unmount();
});
test("shows view more times link only when a section has more than six slots", () => {
const wrapper = mountComponent({ timeSlots: MANY_MORNING_TIME_SLOTS });
const viewMoreLinks = wrapper.findAll(".inshop-scheduling-card__view-more-link");
expect(viewMoreLinks.length).toBe(1);
expect(viewMoreLinks[0].text()).toBe("View more times");
wrapper.unmount();
});
test("expands section when view more times is clicked", async () => {
const wrapper = mountComponent({ timeSlots: MANY_MORNING_TIME_SLOTS });
expect(wrapper.findAll(".inshop-scheduling-card__slot-input").length).toBe(7);
await wrapper.find(".inshop-scheduling-card__view-more-link").trigger("click");
expect(wrapper.findAll(".inshop-scheduling-card__slot-input").length).toBe(8);
expect(wrapper.find(".inshop-scheduling-card__view-more-link").exists()).toBe(false);
wrapper.unmount();
});
test("does not show view more times when six or fewer inshop slots are available", () => {
const wrapper = mountComponent();
expect(wrapper.find(".inshop-scheduling-card__view-more-link").exists()).toBe(false);
wrapper.unmount();
});
test("emits address-clicked when address link is clicked", async () => {
const wrapper = mountComponent();
await wrapper.find(".inshop-scheduling-card__address-link").trigger("click");
expect(wrapper.emitted("address-clicked")).toBeTruthy();
wrapper.unmount();
});
test("emits update:modelValue with route code when an inshop time slot is selected", async () => {
const wrapper = mountComponent();
const morningInput = wrapper.find(`input[value="${MORNING_INSHOP_ROUTE_CODE}"]`);
await morningInput.setValue(true);
expect(wrapper.emitted("update:modelValue")[0][0]).toEqual({
selectedInshopTimeSlot: {
providerNumber: "001820",
routeCodeId: MORNING_INSHOP_ROUTE_CODE,
},
});
wrapper.unmount();
});
test("highlights selected time slot", async () => {
const wrapper = mountComponent({
modelValue: {
selectedInshopTimeSlot: {
providerNumber: "001820",
routeCodeId: MORNING_INSHOP_ROUTE_CODE,
},
},
});
const selectedSlot = wrapper.find(".inshop-scheduling-card__slot--selected");
expect(selectedSlot.exists()).toBe(true);
expect(selectedSlot.text()).toContain("8:00 AM");
wrapper.unmount();
});
test("collapses and expands body when header is clicked", async () => {
const wrapper = mountComponent();
expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false);
await wrapper.find(".inshop-scheduling-card__header").trigger("click");
expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(true);
expect(wrapper.find(".inshop-scheduling-card__address-link").isVisible()).toBe(true);
await wrapper.find(".inshop-scheduling-card__header").trigger("click");
expect(wrapper.find(".inshop-scheduling-card__body").isVisible()).toBe(false);
wrapper.unmount();
});
});

View file

@ -0,0 +1,501 @@
<template>
<div class="inshop-scheduling-card">
<div class="inshop-scheduling-card__panel">
<button
type="button"
class="inshop-scheduling-card__header"
:aria-expanded="isExpanded"
@click="toggleExpanded">
<img
class="inshop-scheduling-card__shop-icon"
src="@/assets/img/shop.svg"
alt=""
aria-hidden="true" />
<span class="inshop-scheduling-card__header-title">{{ cityLabel }}</span>
<span class="inshop-scheduling-card__distance">{{ formattedDistance }}</span>
<span
class="inshop-scheduling-card__appt-badge"
:class="{
'inshop-scheduling-card__appt-badge--empty': appointmentCount === 0,
}">
{{ appointmentCountLabel }}
</span>
<img
class="inshop-scheduling-card__chevron"
:class="{ 'inshop-scheduling-card__chevron--expanded': isExpanded }"
src="@/assets/img/icons/chevron-right-blue.svg"
alt=""
aria-hidden="true" />
</button>
<button
type="button"
class="inshop-scheduling-card__address-link"
@click.stop="onAddressClick">
{{ formattedAddress }}
</button>
<div v-show="isExpanded" class="inshop-scheduling-card__body">
<div v-if="dropOffSection" class="inshop-scheduling-card__section">
<p class="inshop-scheduling-card__section-label">{{ dropOffSection.label }}</p>
<fieldset class="inshop-scheduling-card__time-slots">
<legend class="sr-only">{{ dropOffSection.label }}</legend>
<label
v-for="slot in dropOffSection.slots"
:key="slot.value"
class="inshop-scheduling-card__slot inshop-scheduling-card__slot--full-width"
:class="{
'inshop-scheduling-card__slot--selected': isSlotSelected(slot),
}">
<input
type="radio"
class="inshop-scheduling-card__slot-input"
:name="radioGroupName"
:value="slot.value"
:checked="isSlotSelected(slot)"
@change="onTimeSlotSelected(slot)" />
<span class="inshop-scheduling-card__slot-label">
<span class="inshop-scheduling-card__slot-label-primary">
{{ slot.label }}
</span>
<span
v-if="slot.subText && isSlotSelected(slot)"
class="inshop-scheduling-card__slot-label-secondary"
v-html="slot.subText" />
</span>
</label>
</fieldset>
</div>
<div
v-for="section in displayTimeSlotSections"
:key="section.key"
class="inshop-scheduling-card__section">
<p class="inshop-scheduling-card__section-label">{{ section.label }}</p>
<fieldset
class="inshop-scheduling-card__time-slots inshop-scheduling-card__time-slots--grid">
<legend class="sr-only">{{ section.label }}</legend>
<label
v-for="slot in section.visibleSlots"
:key="slot.value"
class="inshop-scheduling-card__slot"
:class="{
'inshop-scheduling-card__slot--selected': isSlotSelected(slot),
}">
<input
type="radio"
class="inshop-scheduling-card__slot-input"
:name="radioGroupName"
:value="slot.value"
:checked="isSlotSelected(slot)"
@change="onTimeSlotSelected(slot)" />
<span class="inshop-scheduling-card__slot-label">
<span class="inshop-scheduling-card__slot-label-primary">
{{ slot.label }}
</span>
</span>
</label>
</fieldset>
<button
v-if="section.showViewMoreLink"
type="button"
class="inshop-scheduling-card__view-more-link"
@click="onViewMoreTimesClick(section.key)">
{{ viewMoreTimesText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { RouteCodeFlags } from "@/constants/schedule-constants";
import {
groupInshopTimeSlotsByTimeOfDay,
INSHOP_INITIAL_VISIBLE_TIME_SLOTS,
mapInshopTimeSlotToDisplaySlot,
} from "@/layouts/schedule/helpers/schedule-helper";
function toTitleCase(str) {
if (!str) return "";
return str.replace(
/\w\S*/g,
(txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()
);
}
const DROPOFF_CMS_NAME_TO_ROUTE_FLAG = {
AllDayDropOff: RouteCodeFlags.ALL_DAY_DROP_OFF,
OvernightDropOff: RouteCodeFlags.OVERNIGHT_DROP_OFF,
};
export default {
name: "inshop-scheduling-card",
emits: ["update:modelValue", "address-clicked"],
props: {
modelValue: {
type: Object,
default: null,
},
provider: {
type: Object,
required: true,
},
timeSlots: {
type: Array,
default: () => [],
},
radioGroupName: {
type: String,
required: true,
},
cmsWidgetName: {
type: String,
default: "InshopCardWidget",
},
dropoffCmsWidgetName: {
type: String,
default: "DropoffQuestionWidget",
},
},
data() {
return {
isExpanded: false,
expandedTimeSlotSections: {
morning: false,
afternoon: false,
},
};
},
watch: {
timeSlots() {
this.expandedTimeSlotSections = {
morning: false,
afternoon: false,
};
},
},
computed: {
cityLabel() {
return toTitleCase(this.provider?.address?.city);
},
formattedDistance() {
const miles = this.provider?.distanceInMiles;
if (miles == null) {
return "";
}
const rounded = Math.round(miles * 2) / 2;
return `${rounded} mi`;
},
formattedAddress() {
const { streetAddress, city, state, zipCode } = this.provider?.address ?? {};
if (!streetAddress) {
return "";
}
return `${toTitleCase(streetAddress)}, ${toTitleCase(city)}, ${state} ${zipCode}`;
},
morningSectionLabel() {
return this.getCmsContent(this.cmsWidgetName, "BodyText");
},
afternoonSectionLabel() {
return this.getCmsContent(this.cmsWidgetName, "BodyText2");
},
viewMoreTimesText() {
return this.getCmsContent(this.cmsWidgetName, "FooterText");
},
dropOffQuestionLabel() {
return this.getCmsContent(this.dropoffCmsWidgetName, "QuestionText");
},
dropOffAnswers() {
return this.getCmsContent(this.dropoffCmsWidgetName, "Answers") ?? [];
},
dropOffSection() {
if (!this.dropOffQuestionLabel) {
return null;
}
const slots = this.dropOffAnswers
.map((answer) => this.buildDropOffSlotFromAnswer(answer))
.filter(Boolean);
if (!slots.length) {
return null;
}
return {
label: this.dropOffQuestionLabel,
slots,
};
},
timeSlotSections() {
const { morning, afternoon } = groupInshopTimeSlotsByTimeOfDay(this.timeSlots);
return [
{
key: "morning",
label: this.morningSectionLabel,
slots: morning.map(mapInshopTimeSlotToDisplaySlot),
},
{
key: "afternoon",
label: this.afternoonSectionLabel,
slots: afternoon.map(mapInshopTimeSlotToDisplaySlot),
},
].filter((section) => section.label && section.slots.length);
},
displayTimeSlotSections() {
return this.timeSlotSections.map((section) => {
const isExpanded = this.expandedTimeSlotSections[section.key];
const hasMoreThanInitialVisible =
section.slots.length > INSHOP_INITIAL_VISIBLE_TIME_SLOTS;
return {
...section,
visibleSlots: isExpanded
? section.slots
: section.slots.slice(0, INSHOP_INITIAL_VISIBLE_TIME_SLOTS),
showViewMoreLink: hasMoreThanInitialVisible && !isExpanded,
};
});
},
appointmentCount() {
const dropOffCount = this.dropOffSection?.slots.length ?? 0;
const timeSlotCount = this.timeSlotSections.reduce(
(count, section) => count + section.slots.length,
0
);
return dropOffCount + timeSlotCount;
},
appointmentCountLabel() {
return `${this.appointmentCount} appt${this.appointmentCount === 1 ? "" : "s"}`;
},
selectedRouteCodeValue() {
return this.modelValue?.selectedInshopTimeSlot?.routeCodeId ?? null;
},
},
methods: {
toggleExpanded() {
this.isExpanded = !this.isExpanded;
},
onAddressClick() {
this.$emit("address-clicked");
},
onViewMoreTimesClick(sectionKey) {
this.expandedTimeSlotSections = {
...this.expandedTimeSlotSections,
[sectionKey]: true,
};
},
findDropOffTimeSlotForCmsName(cmsName) {
const routeFlag = DROPOFF_CMS_NAME_TO_ROUTE_FLAG[cmsName];
if (!routeFlag) {
return null;
}
return this.timeSlots.find((timeSlot) => timeSlot.id?.includes(routeFlag)) ?? null;
},
buildDropOffSlotFromAnswer(answer) {
const timeSlot = this.findDropOffTimeSlotForCmsName(answer.Name);
if (!timeSlot) {
return null;
}
return {
value: timeSlot.id,
label: answer.Text,
subText: answer.SubText || null,
routeCodeId: timeSlot.id,
};
},
isSlotSelected(slot) {
return this.selectedRouteCodeValue === slot.routeCodeId;
},
onTimeSlotSelected(slot) {
this.$emit("update:modelValue", {
selectedInshopTimeSlot: {
providerNumber: this.provider.providerNumber,
routeCodeId: slot.routeCodeId,
},
});
},
},
};
</script>
<style lang="scss" scoped>
.inshop-scheduling-card {
display: flex;
flex-direction: column;
align-items: flex-start;
&__panel {
width: 100%;
border: 1px solid $gray-1000;
border-radius: 16px;
padding: 16px;
background: $white;
}
&__header {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
border: 0;
background: transparent;
padding: 0;
text-align: left;
}
&__shop-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
&__header-title {
font-size: 1rem;
font-weight: 600;
line-height: 1.5rem;
color: $black;
white-space: nowrap;
}
&__distance {
flex: 1;
font-size: 0.875rem;
line-height: 1.5rem;
color: $gray-600;
white-space: nowrap;
}
&__appt-badge {
background: $blue-150;
color: $blue;
border-radius: 72px;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.25rem;
white-space: nowrap;
&--empty {
background: $gray-100;
color: $gray-500;
}
}
&__chevron {
width: 16px;
height: 16px;
flex-shrink: 0;
transform: rotate(90deg);
transition: transform 150ms linear;
&--expanded {
transform: rotate(-90deg);
}
}
&__address-link {
display: block;
width: 100%;
border: 0;
background: transparent;
padding: 0;
margin-top: 4px;
color: $blue;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5rem;
text-decoration: underline;
text-align: left;
cursor: pointer;
}
&__body {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
&__section-label {
margin: 0 0 8px;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5rem;
color: $black;
}
&__time-slots {
border: 0;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
&--grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
}
&__slot {
display: flex;
align-items: flex-start;
gap: 8px;
border: 1px solid $gray-1000;
border-radius: 8px;
padding: 12px 8px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
cursor: pointer;
background: $white;
&--selected {
border: 2px solid $blue;
background: $blue-150;
}
&--full-width {
width: 100%;
}
}
&__slot-input {
margin-top: 4px;
accent-color: $blue;
flex-shrink: 0;
}
&__slot-label {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
&__slot-label-primary,
&__slot-label-secondary {
font-size: 1rem;
line-height: 1.5rem;
color: $gray-600;
}
&__slot--selected &__slot-label-primary {
color: $black;
font-weight: 600;
}
&__view-more-link {
display: block;
width: 100%;
margin-top: 8px;
border: 0;
background: transparent;
padding: 0;
color: $blue;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.5rem;
text-decoration: underline;
text-align: center;
cursor: pointer;
}
}
</style>

View file

@ -28,6 +28,16 @@
:zipCode="serviceZipCode"
:showFreeFlag="showMobileFreeFlag"
@zip-code-clicked="onMobileZipCodeClicked" />
<inshopSchedulingCard
v-for="{ provider } in inShopProvidersAndTimeslots"
v-show="selectedDate"
:key="provider.providerNumber"
class="mt-4"
v-model="selectedInshopSchedulingByProvider[provider.providerNumber]"
:provider="provider"
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)"
:radioGroupName="`inshopSchedulingTimeSlot-${provider.providerNumber}`"
@address-clicked="onInshopAddressClicked(provider)" />
<navbar
cmsWidgetName="FunnelFooterWidget"
ref="navbar"
@ -46,6 +56,7 @@ import navbar from "@/fmg-components/nav-bar/nav-bar";
import { Form } from "vee-validate";
import datePicker from "@/layouts/scheduling/date-picker/date-picker";
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import store from "@/store";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
@ -145,6 +156,7 @@ export default {
watch: {
selectedDate() {
this.selectedMobileScheduling = null;
this.selectedInshopSchedulingByProvider = {};
},
},
computed: {
@ -197,9 +209,24 @@ export default {
mobileProviderAndTimeSlot: null,
mobilePremiumAppointmentFee: null,
selectedMobileScheduling: null,
selectedInshopSchedulingByProvider: {},
};
},
methods: {
getInshopTimeSlotsForSelectedDate(providerNumber) {
if (!this.selectedDate) {
return [];
}
const providerEntry = this.inShopProvidersAndTimeslots.find(
({ provider }) => provider.providerNumber === providerNumber
);
const day = providerEntry?.timeSlots?.days?.find((d) => d.date === this.selectedDate);
return day?.timeSlots ?? [];
},
onInshopAddressClicked(provider) {
// TODO: open Google Maps when address link functionality is implemented
console.log("onInshopAddressClicked", provider?.providerNumber);
},
onMobileZipCodeClicked() {
// TODO: open service zip modal when zip edit is implemented for scheduling page
},
@ -267,6 +294,7 @@ export default {
Form,
datePicker,
mobileSchedulingCard,
inshopSchedulingCard,
loadingModal,
},
};