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:
parent
b7287e8076
commit
c073984e82
4 changed files with 152 additions and 175 deletions
|
|
@ -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 {
|
#riverside-calendar {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
font-size: 0.9rem;
|
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 ── */
|
/* ── Strip all borders ── */
|
||||||
|
|
@ -139,65 +186,3 @@
|
||||||
height: 3.5rem;
|
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;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,76 @@
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
|
var slotsWrap = document.getElementById('riverside-slots-wrap');
|
||||||
|
var slotsGrid = document.getElementById('riverside-booking-slots');
|
||||||
|
|
||||||
var selectedDate = null;
|
var selectedDate = null;
|
||||||
|
var initialized = false;
|
||||||
|
|
||||||
var panel = document.getElementById('riverside-booking-panel');
|
function nextBusinessDay() {
|
||||||
var backdrop = document.getElementById('riverside-booking-backdrop');
|
var d = new Date();
|
||||||
var panelDate = document.getElementById('riverside-booking-date');
|
d.setDate(d.getDate() + 1);
|
||||||
var panelSlots = document.getElementById('riverside-booking-slots');
|
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
|
||||||
|
return d.toISOString().substring(0, 10);
|
||||||
function closePanel() {
|
|
||||||
panel.hidden = true;
|
|
||||||
backdrop.hidden = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPanel() {
|
var initDate = nextBusinessDay();
|
||||||
backdrop.hidden = false;
|
|
||||||
panel.hidden = false;
|
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, {
|
var calendar = new FullCalendar.Calendar(el, {
|
||||||
initialView: 'dayGridMonth',
|
initialView: 'dayGridMonth',
|
||||||
|
initialDate: initDate,
|
||||||
headerToolbar: { left: 'prev', center: 'title', right: 'next' },
|
headerToolbar: { left: 'prev', center: 'title', right: 'next' },
|
||||||
titleFormat: { year: 'numeric', month: 'long' },
|
titleFormat: { year: 'numeric', month: 'long' },
|
||||||
dayHeaderFormat: { weekday: 'narrow' },
|
dayHeaderFormat: { weekday: 'narrow' },
|
||||||
|
|
@ -43,6 +94,7 @@
|
||||||
d.classList.remove('is-selected');
|
d.classList.remove('is-selected');
|
||||||
});
|
});
|
||||||
selectedDate = null;
|
selectedDate = null;
|
||||||
|
if (slotsWrap) slotsWrap.hidden = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
eventsSet: function (events) {
|
eventsSet: function (events) {
|
||||||
|
|
@ -54,6 +106,14 @@
|
||||||
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
|
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
|
||||||
if (dayEl) dayEl.classList.add('has-availability');
|
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) {
|
dayCellClassNames: function (arg) {
|
||||||
|
|
@ -63,62 +123,11 @@
|
||||||
|
|
||||||
dateClick: function (arg) {
|
dateClick: function (arg) {
|
||||||
if (!arg.dayEl.classList.contains('has-availability')) return;
|
if (!arg.dayEl.classList.contains('has-availability')) return;
|
||||||
|
selectDay(arg.dateStr, calendar.getEvents());
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
|
|
||||||
backdrop.addEventListener('click', closePanel);
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape') closePanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
calendar.render();
|
calendar.render();
|
||||||
}); // end requestAnimationFrame
|
});
|
||||||
});
|
});
|
||||||
})(drupalSettings);
|
})(drupalSettings);
|
||||||
|
|
|
||||||
|
|
@ -30,45 +30,28 @@ class ScheduleController extends ControllerBase {
|
||||||
'#tag' => 'p',
|
'#tag' => 'p',
|
||||||
'#value' => $this->t('View provider availability below. Use the calendar to browse open appointment slots by week.'),
|
'#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' => [
|
'calendar' => [
|
||||||
'#type' => 'html_tag',
|
'#type' => 'html_tag',
|
||||||
'#tag' => 'div',
|
'#tag' => 'div',
|
||||||
'#attributes' => ['id' => 'riverside-calendar'],
|
'#attributes' => ['id' => 'riverside-calendar'],
|
||||||
],
|
|
||||||
'booking_backdrop' => [
|
|
||||||
'#type' => 'html_tag',
|
|
||||||
'#tag' => 'div',
|
|
||||||
'#attributes' => ['id' => 'riverside-booking-backdrop', 'hidden' => TRUE],
|
|
||||||
'#value' => '',
|
'#value' => '',
|
||||||
],
|
],
|
||||||
'booking_panel' => [
|
'slots_wrap' => [
|
||||||
'#type' => 'html_tag',
|
'#type' => 'html_tag',
|
||||||
'#tag' => 'div',
|
'#tag' => 'div',
|
||||||
'#attributes' => ['id' => 'riverside-booking-panel', 'hidden' => TRUE],
|
'#attributes' => ['id' => 'riverside-slots-wrap', '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('✕'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'slots' => [
|
'slots' => [
|
||||||
'#type' => 'html_tag',
|
'#type' => 'html_tag',
|
||||||
'#tag' => 'ul',
|
'#tag' => 'div',
|
||||||
'#attributes' => ['id' => 'riverside-booking-slots'],
|
'#attributes' => ['id' => 'riverside-booking-slots'],
|
||||||
'#value' => '',
|
'#value' => '',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
],
|
||||||
'#attached' => [
|
'#attached' => [
|
||||||
'library' => ['riverside_pt/schedule'],
|
'library' => ['riverside_pt/schedule'],
|
||||||
'drupalSettings' => [
|
'drupalSettings' => [
|
||||||
|
|
@ -123,6 +106,8 @@ class ScheduleController extends ControllerBase {
|
||||||
$id = 1;
|
$id = 1;
|
||||||
|
|
||||||
while ($current < $until) {
|
while ($current < $until) {
|
||||||
|
$dow = (int) $current->format('N'); // 1=Mon … 7=Sun
|
||||||
|
if ($dow <= 5) {
|
||||||
$i = (int) floor($current->getTimestamp() / 86400);
|
$i = (int) floor($current->getTimestamp() / 86400);
|
||||||
$count = ($i % 5 + $i % 7 + $i % 11) % 6;
|
$count = ($i % 5 + $i % 7 + $i % 11) % 6;
|
||||||
for ($n = 0; $n < $count; $n++) {
|
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'),
|
'end' => (clone $slot)->modify('+1 hour')->format('Y-m-d\TH:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
$current->modify('+1 day');
|
$current->modify('+1 day');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,16 +116,13 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="py-24 px-6 bg-white">
|
<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>
|
<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-calendar"></div>
|
||||||
<div id="riverside-booking-backdrop" hidden></div>
|
<div id="riverside-slots-wrap" hidden>
|
||||||
<div id="riverside-booking-panel" hidden>
|
<div id="riverside-booking-slots"></div>
|
||||||
<div class="riverside-booking-header">
|
|
||||||
<span id="riverside-booking-date"></span>
|
|
||||||
<button id="riverside-booking-close" type="button">✕</button>
|
|
||||||
</div>
|
</div>
|
||||||
<ul id="riverside-booking-slots"></ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue