Redesign booking calendar: inline slots, M-F only, pre-select next day

- Slots panel moves from popup to side-by-side with the calendar
- First available slot is pre-selected (highlighted) on load
- Calendar initializes to the next business day
- eventsSet auto-selects that date if it has availability
- Events endpoint now skips Sat/Sun (N > 5)
- Removed backdrop/close-button popup infrastructure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-06-03 02:02:56 -07:00
parent b7287e8076
commit c073984e82
4 changed files with 152 additions and 175 deletions

View file

@ -1,6 +1,53 @@
.riverside-booking-wrap {
display: flex;
flex-direction: column;
gap: 2rem;
align-items: flex-start;
}
@media (min-width: 640px) {
.riverside-booking-wrap {
flex-direction: row;
gap: 3rem;
}
}
#riverside-calendar {
max-width: 480px;
font-size: 0.9rem;
flex-shrink: 0;
}
#riverside-slots-wrap {
flex: 1;
padding-top: 3rem;
}
#riverside-booking-slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.riverside-slot-btn {
padding: 0.6rem 0.5rem;
border: 1.5px solid #306f8e;
border-radius: 6px;
font-size: 0.8rem;
color: #306f8e;
background: #fff;
cursor: pointer;
text-align: center;
transition: background 0.15s, color 0.15s;
}
.riverside-slot-btn:hover:not(.is-selected) {
background: #dde8f0;
}
.riverside-slot-btn.is-selected {
background: #306f8e;
color: #fff;
}
/* ── Strip all borders ── */
@ -139,65 +186,3 @@
height: 3.5rem;
}
/* ── Booking backdrop & panel ── */
#riverside-booking-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999;
}
#riverside-booking-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 340px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 1rem;
z-index: 1000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.riverside-booking-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
font-weight: 600;
}
#riverside-booking-close {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0;
color: #6b7280;
}
#riverside-booking-slots {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#riverside-booking-slots li {
padding: 0.5rem 0.75rem;
border: 1px solid #306f8e;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
color: #306f8e;
}
#riverside-booking-slots li:hover {
background: #dde8f0;
}

View file

@ -4,25 +4,76 @@
if (!el) return;
requestAnimationFrame(function () {
var slotsWrap = document.getElementById('riverside-slots-wrap');
var slotsGrid = document.getElementById('riverside-booking-slots');
var selectedDate = null;
var initialized = false;
var panel = document.getElementById('riverside-booking-panel');
var backdrop = document.getElementById('riverside-booking-backdrop');
var panelDate = document.getElementById('riverside-booking-date');
var panelSlots = document.getElementById('riverside-booking-slots');
function closePanel() {
panel.hidden = true;
backdrop.hidden = true;
function nextBusinessDay() {
var d = new Date();
d.setDate(d.getDate() + 1);
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
return d.toISOString().substring(0, 10);
}
function openPanel() {
backdrop.hidden = false;
panel.hidden = false;
var initDate = nextBusinessDay();
function slotLabel(date) {
var h = date.getHours();
return (h % 12 || 12) + (h < 12 ? 'AM' : 'PM') + ' PST';
}
function renderSlots(dateStr, events) {
var dayEvents = events
.filter(function (e) { return e.startStr.startsWith(dateStr); })
.sort(function (a, b) { return a.start - b.start; });
if (!slotsWrap || !slotsGrid || dayEvents.length === 0) return;
slotsGrid.innerHTML = '';
dayEvents.forEach(function (event, idx) {
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = slotLabel(event.start);
btn.className = 'riverside-slot-btn' + (idx === 0 ? ' is-selected' : '');
btn.addEventListener('click', function () {
slotsGrid.querySelectorAll('.riverside-slot-btn').forEach(function (b) {
b.classList.remove('is-selected');
});
btn.classList.add('is-selected');
fetch(drupalSettings.riversidePt.storeSlotUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: event.startStr, end: event.endStr }),
}).then(function (res) {
if (res.ok) {
window.location.href = drupalSettings.riversidePt.bookingUrl;
} else {
btn.textContent += ' (unavailable)';
btn.disabled = true;
}
});
});
slotsGrid.appendChild(btn);
});
slotsWrap.hidden = false;
}
function selectDay(dateStr, events) {
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
d.classList.remove('is-selected');
});
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
if (dayEl) dayEl.classList.add('is-selected');
selectedDate = dateStr;
renderSlots(dateStr, events);
}
var calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
initialDate: initDate,
headerToolbar: { left: 'prev', center: 'title', right: 'next' },
titleFormat: { year: 'numeric', month: 'long' },
dayHeaderFormat: { weekday: 'narrow' },
@ -43,6 +94,7 @@
d.classList.remove('is-selected');
});
selectedDate = null;
if (slotsWrap) slotsWrap.hidden = true;
},
eventsSet: function (events) {
@ -54,6 +106,14 @@
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
if (dayEl) dayEl.classList.add('has-availability');
});
if (!initialized) {
initialized = true;
var targetEl = el.querySelector('.fc-daygrid-day[data-date="' + initDate + '"]');
if (targetEl && targetEl.classList.contains('has-availability')) {
selectDay(initDate, events);
}
}
},
dayCellClassNames: function (arg) {
@ -63,62 +123,11 @@
dateClick: function (arg) {
if (!arg.dayEl.classList.contains('has-availability')) return;
var dateStr = arg.dateStr;
var dayEvents = calendar.getEvents().filter(function (e) {
return e.startStr.startsWith(dateStr);
});
if (dayEvents.length === 0) return;
// Update selected highlight.
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
d.classList.remove('is-selected');
});
arg.dayEl.classList.add('is-selected');
selectedDate = dateStr;
// Build slot list.
panelDate.textContent = arg.date.toLocaleDateString(undefined, {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
panelSlots.innerHTML = '';
dayEvents.sort(function (a, b) { return a.start - b.start; }).forEach(function (event) {
var startLabel = event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
var endLabel = event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
var li = document.createElement('li');
var a = document.createElement('a');
a.href = '#';
a.textContent = startLabel + ' ' + endLabel;
a.addEventListener('click', function (e) {
e.preventDefault();
fetch(drupalSettings.riversidePt.storeSlotUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: event.startStr, end: event.endStr }),
}).then(function (res) {
if (res.ok) {
window.location.href = drupalSettings.riversidePt.bookingUrl;
} else {
a.textContent += ' (no longer available)';
a.style.pointerEvents = 'none';
a.style.opacity = '0.5';
}
});
});
li.appendChild(a);
panelSlots.appendChild(li);
});
openPanel();
selectDay(arg.dateStr, calendar.getEvents());
},
});
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePanel();
});
calendar.render();
}); // end requestAnimationFrame
});
});
})(drupalSettings);

View file

@ -30,45 +30,28 @@ class ScheduleController extends ControllerBase {
'#tag' => 'p',
'#value' => $this->t('View provider availability below. Use the calendar to browse open appointment slots by week.'),
],
'booking_wrap' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['class' => ['riverside-booking-wrap']],
'calendar' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-calendar'],
],
'booking_backdrop' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-booking-backdrop', 'hidden' => TRUE],
'#value' => '',
],
'booking_panel' => [
'slots_wrap' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-booking-panel', 'hidden' => TRUE],
'header' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['class' => ['riverside-booking-header']],
'title' => [
'#type' => 'html_tag',
'#tag' => 'span',
'#attributes' => ['id' => 'riverside-booking-date'],
'#value' => '',
],
'close' => [
'#type' => 'html_tag',
'#tag' => 'button',
'#attributes' => ['id' => 'riverside-booking-close', 'type' => 'button'],
'#value' => $this->t('✕'),
],
],
'#attributes' => ['id' => 'riverside-slots-wrap', 'hidden' => TRUE],
'slots' => [
'#type' => 'html_tag',
'#tag' => 'ul',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-booking-slots'],
'#value' => '',
],
],
],
'#attached' => [
'library' => ['riverside_pt/schedule'],
'drupalSettings' => [
@ -123,6 +106,8 @@ class ScheduleController extends ControllerBase {
$id = 1;
while ($current < $until) {
$dow = (int) $current->format('N'); // 1=Mon … 7=Sun
if ($dow <= 5) {
$i = (int) floor($current->getTimestamp() / 86400);
$count = ($i % 5 + $i % 7 + $i % 11) % 6;
for ($n = 0; $n < $count; $n++) {
@ -135,6 +120,7 @@ class ScheduleController extends ControllerBase {
'end' => (clone $slot)->modify('+1 hour')->format('Y-m-d\TH:i:s'),
];
}
}
$current->modify('+1 day');
}

View file

@ -116,16 +116,13 @@
</section>
<section class="py-24 px-6 bg-white">
<div class="max-w-[560px] mx-auto">
<div class="max-w-[900px] mx-auto">
<h2 class="text-[clamp(2.5rem,5vw,4rem)] font-serif font-light text-gray-800 mb-10 text-center">Book An Appointment</h2>
<div class="riverside-booking-wrap">
<div id="riverside-calendar"></div>
<div id="riverside-booking-backdrop" hidden></div>
<div id="riverside-booking-panel" hidden>
<div class="riverside-booking-header">
<span id="riverside-booking-date"></span>
<button id="riverside-booking-close" type="button">&#x2715;</button>
<div id="riverside-slots-wrap" hidden>
<div id="riverside-booking-slots"></div>
</div>
<ul id="riverside-booking-slots"></ul>
</div>
</div>
</section>