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 {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">✕</button>
|
||||
<div id="riverside-slots-wrap" hidden>
|
||||
<div id="riverside-booking-slots"></div>
|
||||
</div>
|
||||
<ul id="riverside-booking-slots"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
Loading…
Reference in a new issue