541 lines
18 KiB
Vue
541 lines
18 KiB
Vue
<template>
|
|
<div class="inshop-scheduling-card">
|
|
<div v-if="isLoading" class="inshop-scheduling-card__panel">
|
|
<schedulingCardLoader />
|
|
</div>
|
|
<div v-else 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-no-background.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">
|
|
<availableAppointmentDateChips
|
|
v-if="showAvailableDateChips"
|
|
:displayedDates="availableDateChips"
|
|
:label="availableDatesLabel"
|
|
@date-selected="$emit('date-selected', $event)" />
|
|
<template v-else>
|
|
<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>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { RouteCodeFlags, AppointmentTypeStrings } from "@/constants/schedule-constants";
|
|
import {
|
|
groupInshopTimeSlotsByTimeOfDay,
|
|
INSHOP_INITIAL_VISIBLE_TIME_SLOTS,
|
|
mapInshopTimeSlotToDisplaySlot,
|
|
} from "@/layouts/schedule/helpers/schedule-helper";
|
|
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader";
|
|
import availableAppointmentDateChips from "@/layouts/scheduling/available-appointment-date-chips/available-appointment-date-chips.vue";
|
|
|
|
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", "date-selected"],
|
|
props: {
|
|
modelValue: {
|
|
type: Object,
|
|
default: null,
|
|
},
|
|
provider: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
timeSlots: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
radioGroupName: {
|
|
type: String,
|
|
default: "schedulingTimeSlot",
|
|
},
|
|
cmsWidgetName: {
|
|
type: String,
|
|
default: "InshopCardWidget",
|
|
},
|
|
dropoffCmsWidgetName: {
|
|
type: String,
|
|
default: "DropoffQuestionWidget",
|
|
},
|
|
isLoading: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
availableDateChips: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
},
|
|
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 * 10) / 10;
|
|
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"}`;
|
|
},
|
|
showAvailableDateChips() {
|
|
return this.availableDateChips.length > 0 && this.appointmentCount === 0;
|
|
},
|
|
availableDatesLabel() {
|
|
return (
|
|
this.getCmsContent(this.cmsWidgetName, "AvailableDatesLabel") ||
|
|
"Available appointments:"
|
|
);
|
|
},
|
|
selectedRouteCodeValue() {
|
|
if (
|
|
this.modelValue?.appointmentType !== AppointmentTypeStrings.IN_SHOP ||
|
|
this.modelValue?.providerNumber !== this.provider.providerNumber
|
|
) {
|
|
return null;
|
|
}
|
|
return this.modelValue?.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", {
|
|
appointmentType: AppointmentTypeStrings.IN_SHOP,
|
|
providerNumber: this.provider.providerNumber,
|
|
routeCodeId: slot.routeCodeId,
|
|
});
|
|
},
|
|
},
|
|
components: {
|
|
schedulingCardLoader,
|
|
availableAppointmentDateChips,
|
|
},
|
|
};
|
|
</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(180deg);
|
|
transition: transform 150ms linear;
|
|
|
|
&--expanded {
|
|
transform: rotate(0deg);
|
|
}
|
|
}
|
|
|
|
&__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>
|