Calendar polish: selection persistence, no-slots overlay, various fixes

- Persist selected date across month navigation using module-level vars;
  datesSet re-applies is-selected and restores slots when returning
- Show "No availability this month" overlay after a fetch returns empty;
  gated on initializedRef+fetchedRef so auto-advance phase is silent
- Fix Dec 31 overflow: showNonCurrentDates:false hides adjacent-month days
- Fix fc-day-disabled background tint in calendar.css
- Gate auto-advance on loading() callback so removeAllEventSources()
  spurious eventsSet() fires don't trigger premature month jumping
- Inline overlay styles to avoid Tailwind cascade uncertainty; document
  the module-level CX constant pattern as the general fix
- firstName added to booking form; storeSlot sends email directly when
  full contact info present, skipping tempstore redirect
- Remove BookingForm.php and /schedule/book route (replaced by inline form)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip Peterson 2026-06-03 20:51:43 -07:00
parent e3c2e3e3a1
commit 1b7577fa17
13 changed files with 188 additions and 263 deletions

View file

@ -741,6 +741,14 @@ html {
margin-top: 2rem;
}
.mt-1{
margin-top: 0.25rem;
}
.mt-3{
margin-top: 0.75rem;
}
.block{
display: block;
}
@ -1086,6 +1094,11 @@ html {
border-color: rgb(255 255 255 / 0.6);
}
.border-green-200{
--tw-border-opacity: 1;
border-color: rgb(187 247 208 / var(--tw-border-opacity, 1));
}
.bg-current{
background-color: currentColor;
}
@ -1138,6 +1151,11 @@ html {
background-color: rgb(255 255 255 / 0.9);
}
.bg-green-50{
--tw-bg-opacity: 1;
background-color: rgb(240 253 244 / var(--tw-bg-opacity, 1));
}
.bg-gradient-to-b{
background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
}
@ -1488,6 +1506,20 @@ html {
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
}
.text-green-700{
--tw-text-opacity: 1;
color: rgb(21 128 61 / var(--tw-text-opacity, 1));
}
.text-green-800{
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity, 1));
}
.underline{
text-decoration-line: underline;
}
.no-underline{
text-decoration-line: none;
}
@ -1593,6 +1625,11 @@ html {
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.hover\:text-green-800:hover{
--tw-text-opacity: 1;
color: rgb(22 101 52 / var(--tw-text-opacity, 1));
}
.focus\:border-pt-blue-500:focus{
--tw-border-opacity: 1;
border-color: rgb(48 111 142 / var(--tw-border-opacity, 1));
@ -1619,6 +1656,10 @@ html {
}
@media (min-width: 768px){
.sm\:col-span-2{
grid-column: span 2 / span 2;
}
.sm\:block{
display: block;
}

View file

@ -73,13 +73,6 @@
color: #111;
}
#riverside-calendar .fc-toolbar-title::after {
content: ' \25BC';
font-size: 0.6em;
vertical-align: middle;
color: #374151;
}
#riverside-calendar .fc-button-group,
#riverside-calendar .fc-button {
background: none !important;
@ -179,6 +172,11 @@
background: none !important;
}
/* Disabled days (outside validRange) — no background tint */
#riverside-calendar .fc-day-disabled {
background: none !important;
}
/* Weekends — same appearance as any other non-available day */
#riverside-calendar .fc-day-sat,
#riverside-calendar .fc-day-sun {

View file

@ -95,6 +95,7 @@
};
},
fixedWeekCount: false,
showNonCurrentDates: false,
height: 'auto',
events: buildEventsUrl(currentService),
eventDisplay: 'none',

View file

@ -13,7 +13,7 @@ const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" x
<polyline points="1,5.5 5,9.5 13,1" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
const EMPTY_FORM = { lastName: "", phone: "", comments: "" };
const EMPTY_FORM = { firstName: "", lastName: "", phone: "", comments: "" };
function formatPhone(raw) {
let d = String(raw || "").replace(/\D/g, "");
@ -57,6 +57,8 @@ function Booking({ settings }) {
const [formData, setFormData] = useState(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const [success, setSuccess] = useState(false);
const [noSlotsInMonth, setNoSlotsInMonth] = useState(false);
const calEl = useRef(null);
const calRef = useRef(null);
@ -111,6 +113,7 @@ function Booking({ settings }) {
d.classList.remove("is-selected");
});
setSelectedSlotId(null);
setNoSlotsInMonth(false);
if (selectedDate) {
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]");
if (dayEl) {
@ -148,6 +151,12 @@ function Booking({ settings }) {
cal.next();
}
}
if (initializedRef.current && fetchedRef.current) {
var viewStart = cal.view.currentStart;
var viewEnd = cal.view.currentEnd;
var inView = events.filter(function (e) { return e.start >= viewStart && e.start < viewEnd; });
setNoSlotsInMonth(inView.length === 0);
}
},
dayCellClassNames: function (arg) {
@ -168,6 +177,7 @@ function Booking({ settings }) {
selectedDateSlots = daySlots;
setSelectedSlotId(null);
setSubmitError(null);
setSuccess(false);
setSlots(daySlots);
},
});
@ -193,6 +203,8 @@ function Booking({ settings }) {
setSelectedSlotId(null);
setFormData(EMPTY_FORM);
setSubmitError(null);
setSuccess(false);
setNoSlotsInMonth(false);
cal.gotoDate(initDate);
}
@ -203,6 +215,7 @@ function Booking({ settings }) {
function handleSlotClick(slot) {
setSelectedSlotId(slot.id);
setSubmitError(null);
setSuccess(false);
}
function handleFormChange(field, value) {
@ -222,16 +235,25 @@ function Booking({ settings }) {
start: slot.startStr,
end: slot.endStr,
service: service,
firstName: formData.firstName,
lastName: formData.lastName,
phone: formData.phone,
comments: formData.comments,
}),
}).then(function (res) {
if (res.ok) {
window.location.href = settings.bookingUrl;
setSubmitting(false);
setSubmitError(null);
setSuccess(true);
setSelectedSlotId(null);
setFormData(EMPTY_FORM);
} else {
setSubmitting(false);
setSubmitError("Something went wrong. Please try again.");
if (res.status === 422) {
setSubmitError("That slot was just booked. Please choose another time.");
} else {
setSubmitError("Something went wrong. Please try again.");
}
}
}).catch(function () {
setSubmitting(false);
@ -280,7 +302,16 @@ function Booking({ settings }) {
</div>
<div class="riverside-booking-wrap">
<div ref=${calEl} id="riverside-calendar"></div>
<div style="position:relative">
<div ref=${calEl} id="riverside-calendar"></div>
${noSlotsInMonth ? html`
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding-top:5rem;pointer-events:none;">
<p style="font-size:0.875rem;color:#6b7280;border:1px solid #b8d4dc;background:#fff;padding:0.5rem 1rem;">
No availability this month
</p>
</div>
` : null}
</div>
${slots.length > 0 ? html`
<div id="riverside-slots-wrap">
<div id="riverside-booking-slots">
@ -299,6 +330,28 @@ function Booking({ settings }) {
` : null}
</div>
${success ? html`
<div class="mt-8 pt-8 border-t border-pt-blue-200">
<div class="p-6 bg-green-50 border border-green-200 text-green-800">
<p class="font-medium">Request received!</p>
<p class="text-sm mt-1">Thank you. We'll contact you shortly to confirm your appointment.</p>
<button
type="button"
onClick=${function () {
setSuccess(false);
setFormData(EMPTY_FORM);
if (calEl.current) {
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected");
});
}
}}
class="mt-3 text-sm text-green-700 underline hover:text-green-800"
>Book another appointment</button>
</div>
</div>
` : null}
${selectedSlot ? html`
<form
onSubmit=${handleSubmit}
@ -309,6 +362,18 @@ function Booking({ settings }) {
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5 mb-5">
<div>
<label class=${labelClass}>
First name <span class="text-red-500">*</span>
</label>
<input
type="text"
required
value=${formData.firstName}
onInput=${function (e) { handleFormChange("firstName", e.target.value); }}
class=${inputClass}
/>
</div>
<div>
<label class=${labelClass}>
Last name <span class="text-red-500">*</span>
@ -321,7 +386,7 @@ function Booking({ settings }) {
class=${inputClass}
/>
</div>
<div>
<div class="sm:col-span-2">
<label class=${labelClass}>
Phone number <span class="text-red-500">*</span>
</label>

View file

@ -21,7 +21,7 @@ const FAQS = [
},
{
q: "Can I book an appointment online?",
a: "Yes. Use the booking tool on our Schedule page to pick a service type, choose an available slot, and confirm your appointment. You'll receive a confirmation email immediately.",
a: "Yes. Use the booking tool on this page to pick a service type, choose an available slot, and submit your request. You'll receive a confirmation email immediately.",
},
{
q: "What should I wear or bring to my session?",

View file

@ -223,7 +223,7 @@ function _riverside_pt_build_navigation(): void {
['title' => 'FAQ', 'uri' => 'internal:/faq', 'weight' => 3, 'class' => NULL],
['title' => 'Contact', 'uri' => 'internal:/contact', 'weight' => 4, 'class' => 'nav-cta nav-cta--primary'],
['title' => 'Book An Appointment', 'uri' => 'internal:/schedule', 'weight' => 5, 'class' => 'nav-cta nav-cta--primary'],
['title' => 'Book An Appointment', 'uri' => 'internal:/home', 'weight' => 5, 'class' => 'nav-cta nav-cta--primary'],
];
foreach ($defs as $def) {

View file

@ -15,7 +15,6 @@ schedule:
css/calendar.css: {}
js:
js/fullcalendar.min.js: { minified: true }
js/calendar.js: {}
js/components/rpt-booking.js: { attributes: { type: module } }
dependencies:
- core/drupalSettings

View file

@ -74,14 +74,6 @@ function riverside_pt_preprocess_riverside_pt_header(array &$variables): void {
$variables['current_path'] = \Drupal::request()->getPathInfo();
}
function riverside_pt_system_breadcrumb_alter(Breadcrumb &$breadcrumb, RouteMatchInterface $route_match, array $context): void {
if ($route_match->getRouteName() === 'riverside_pt.booking') {
$breadcrumb = new Breadcrumb();
$breadcrumb->addLink(Link::createFromRoute('← Back', 'riverside_pt.schedule'));
$breadcrumb->addCacheContexts(['route']);
}
}
function riverside_pt_mail(string $key, array &$message, array $params): void {
if ($key !== 'booking_request') {
return;

View file

@ -14,22 +14,6 @@ riverside_pt.home:
requirements:
_permission: 'access content'
riverside_pt.schedule:
path: '/schedule'
defaults:
_controller: '\Drupal\riverside_pt\Controller\ScheduleController::page'
_title: 'Schedule'
requirements:
_permission: 'access content'
riverside_pt.booking:
path: '/schedule/book'
defaults:
_form: '\Drupal\riverside_pt\Form\BookingForm'
_title: 'Request Appointment'
requirements:
_permission: 'access content'
riverside_pt.booking_store_slot:
path: '/schedule/book/slot'
defaults:

View file

@ -21,7 +21,6 @@ class HomeController extends ControllerBase {
'drupalSettings' => [
'riversidePt' => [
'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
'bookingUrl' => Url::fromRoute('riverside_pt.booking')->toString(),
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
'holidays' => $holidayMap,
],

View file

@ -5,7 +5,8 @@ namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@ -13,66 +14,23 @@ use Symfony\Component\HttpFoundation\Request;
class ScheduleController extends ControllerBase {
private PrivateTempStore $tempStore;
private $configFactory;
public function __construct(PrivateTempStoreFactory $tempStoreFactory) {
public function __construct(
PrivateTempStoreFactory $tempStoreFactory,
private readonly MailManagerInterface $mailManager,
ConfigFactoryInterface $configFactory,
) {
$this->tempStore = $tempStoreFactory->get('riverside_pt');
$this->configFactory = $configFactory;
}
public static function create(ContainerInterface $container): static {
return new static($container->get('tempstore.private'));
}
public function page(): array {
return [
'#type' => 'container',
'intro' => [
'#type' => 'html_tag',
'#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'],
'#value' => '',
],
'slots_wrap' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-slots-wrap', 'hidden' => TRUE],
'slots' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-booking-slots'],
'#value' => '',
],
],
],
'#attached' => [
'library' => ['riverside_pt/schedule'],
'drupalSettings' => [
'riversidePt' => [
'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
'bookingUrl' => Url::fromRoute('riverside_pt.booking')->toString(),
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
'holidays' => $this->buildHolidaysMap(),
],
],
],
];
}
private function buildHolidaysMap(): array {
$holidays = $this->config('riverside_pt.settings')->get('holidays') ?? [];
$map = [];
foreach ($holidays as $holiday) {
$map[$holiday['date']] = $holiday['name'];
}
return $map;
return new static(
$container->get('tempstore.private'),
$container->get('plugin.manager.mail'),
$container->get('config.factory'),
);
}
public function storeSlot(Request $request): JsonResponse {
@ -83,14 +41,61 @@ class ScheduleController extends ControllerBase {
return new JsonResponse(['error' => 'past'], 422);
}
$firstName = trim($data['firstName'] ?? $data['first_name'] ?? '');
$lastName = trim($data['lastName'] ?? $data['last_name'] ?? '');
$phone = trim($data['phone'] ?? '');
$comments = $data['comments'] ?? '';
$service = $data['service'] ?? 'diagnostic';
$end = $data['end'] ?? '';
$providerId = $data['provider_id'] ?? '';
// Full contact info present (new embedded booking flow on homepage):
// validate, send the request email immediately, and return success.
// This replaces the previous /schedule/book form page.
if ($firstName && $lastName && $phone) {
// Prevent double-booking against existing appointment nodes (same logic as before).
$conflict = \Drupal::entityQuery('node')
->condition('type', 'appointment')
->condition('field_appointment_date', $start)
->condition('field_provider', $providerId ?: 0)
->accessCheck(FALSE)
->count()
->execute();
if ($conflict > 0) {
return new JsonResponse(['error' => 'conflict'], 422);
}
$to = $this->configFactory->get('riverside_pt.settings')->get('notification_email');
$lang = \Drupal::languageManager()->getDefaultLanguage()->getId();
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [
'first_name' => $firstName,
'last_name' => $lastName,
'phone' => $phone,
'comments' => $comments,
'start' => $start,
'end' => $end,
]);
$this->tempStore->delete('booking_slot');
if ($sent['result']) {
return new JsonResponse(['ok' => TRUE]);
}
return new JsonResponse(['error' => 'mail_failed'], 500);
}
// Legacy/minimal path (no contact details): just stash in tempstore (for any
// remaining callers that don't send full info).
$this->tempStore->set('booking_slot', [
'start' => $start,
'end' => $data['end'] ?? '',
'service' => $data['service'] ?? 'diagnostic',
'last_name' => $data['lastName'] ?? '',
'phone' => $data['phone'] ?? '',
'comments' => $data['comments'] ?? '',
'provider_id' => $data['provider_id'] ?? '',
'end' => $end,
'service' => $service,
'last_name' => $lastName,
'phone' => $phone,
'comments' => $comments,
'provider_id' => $providerId,
]);
return new JsonResponse(['ok' => TRUE]);

View file

@ -1,159 +0,0 @@
<?php
namespace Drupal\riverside_pt\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\user\Entity\User;
use Symfony\Component\DependencyInjection\ContainerInterface;
class BookingForm extends FormBase {
private PrivateTempStore $tempStore;
public function __construct(
private readonly MailManagerInterface $mailManager,
ConfigFactoryInterface $configFactory,
PrivateTempStoreFactory $tempStoreFactory,
) {
$this->configFactory = $configFactory;
$this->tempStore = $tempStoreFactory->get('riverside_pt');
}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('plugin.manager.mail'),
$container->get('config.factory'),
$container->get('tempstore.private'),
);
}
public function getFormId(): string {
return 'riverside_pt_booking_form';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$slot = $this->tempStore->get('booking_slot') ?? [];
$start = $slot['start'] ?? '';
$end = $slot['end'] ?? '';
$uid = $slot['provider_id'] ?? '';
$slot_display = '';
if ($start && $end) {
$s = new \DateTime($start);
$e = new \DateTime($end);
$slot_display = $s->format('l, F j, Y') . ', ' . $s->format('g:i A') . '' . $e->format('g:i A');
}
$form['#cache'] = ['max-age' => 0];
$form['slot_summary'] = [
'#type' => 'item',
'#title' => $this->t('Appointment'),
'#markup' => $slot_display ?: $this->t('No slot selected.'),
];
if ($uid && $provider = User::load($uid)) {
$form['provider_summary'] = [
'#type' => 'item',
'#title' => $this->t('Provider'),
'#markup' => $provider->getDisplayName(),
];
}
$form['first_name'] = [
'#type' => 'textfield',
'#title' => $this->t('First name'),
'#required' => TRUE,
];
$form['last_name'] = [
'#type' => 'textfield',
'#title' => $this->t('Last name'),
'#required' => TRUE,
'#default_value' => $slot['last_name'] ?? '',
];
$form['phone'] = [
'#type' => 'tel',
'#title' => $this->t('Phone number'),
'#required' => TRUE,
'#default_value' => $slot['phone'] ?? '',
'#attributes' => ['class' => ['rpt-phone']],
];
$form['comments'] = [
'#type' => 'textarea',
'#title' => $this->t('Comments'),
'#rows' => 4,
'#default_value' => $slot['comments'] ?? '',
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Request appointment'),
];
return $form;
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
$slot = $this->tempStore->get('booking_slot') ?? [];
$start = $slot['start'] ?? '';
if (!$start) {
$form_state->setError($form['slot_summary'], $this->t('No slot selected. Please go back and choose a time.'));
return;
}
if (new \DateTime($start) < new \DateTime()) {
$form_state->setError($form['slot_summary'], $this->t('That slot is in the past. Please go back and choose another time.'));
return;
}
$provider_id = $slot['provider_id'] ?? '';
$conflict = \Drupal::entityQuery('node')
->condition('type', 'appointment')
->condition('field_appointment_date', $start)
->condition('field_provider', $provider_id ?: 0)
->accessCheck(FALSE)
->count()
->execute();
if ($conflict > 0) {
$form_state->setError($form['slot_summary'], $this->t('That slot was just booked. Please go back and choose another time.'));
}
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$slot = $this->tempStore->get('booking_slot') ?? [];
$this->tempStore->delete('booking_slot');
$to = $this->configFactory->get('riverside_pt.settings')->get('notification_email');
$lang = $this->languageManager()->getDefaultLanguage()->getId();
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [
'first_name' => $form_state->getValue('first_name'),
'last_name' => $form_state->getValue('last_name'),
'phone' => $form_state->getValue('phone'),
'comments' => $form_state->getValue('comments'),
'start' => $slot['start'] ?? '',
'end' => $slot['end'] ?? '',
]);
if ($sent['result']) {
$this->messenger()->addStatus($this->t('Your request has been submitted. We will contact you to confirm.'));
}
else {
$this->messenger()->addError($this->t('Something went wrong. Please call us to book directly.'));
}
$form_state->setRedirect('riverside_pt.schedule');
}
}

View file

@ -30,7 +30,7 @@
<p class="text-white/80 leading-tight text-[clamp(1rem,2vw,1.5vw)]">Every new patient starts with a comprehensive diagnostic assessment. From there we build a personalized plan that may include sports rehabilitation, pre- or post-surgical recovery, or neurological physical therapy.</p>
<div class="flex gap-4 flex-wrap items-center mt-[2vw]">
<a
href="/schedule"
href="#book-an-appointment"
class="w-full sm:w-auto text-center max-sm:text-sm sm:text-[clamp(0.25rem,1vw,1.25vw)] px-[4em] py-[1em] bg-pt-blue-500 text-white font-medium no-underline transition-colors border-2 border-pt-blue-500 hover:bg-pt-blue-600 hover:border-pt-blue-600"
>Book An Appointment</a>
<a
@ -115,7 +115,7 @@
<rpt-testimonials class="block"></rpt-testimonials>
</section>
<section class="py-24 px-6 bg-white">
<section id="book-an-appointment" class="py-24 px-6 bg-white">
<div class="max-w-[700px] 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>
<rpt-booking class="block"></rpt-booking>