DigitalConsumer.FixMyGlass/src/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card.vue
2026-07-30 13:12:15 -04:00

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>