From 9454dc03b450f7ed8705662955a13a6bd7f52c1d Mon Sep 17 00:00:00 2001 From: Mork Swork Date: Wed, 13 May 2026 15:26:33 -0700 Subject: [PATCH] wip flow --- .../custom/riverside_pt/js/calendar.js | 31 ++++++-- .../custom/riverside_pt/riverside_pt.module | 22 +++++- .../riverside_pt/riverside_pt.routing.yml | 8 ++ .../src/Controller/ScheduleController.php | 41 +++++++++-- .../riverside_pt/src/Form/BookingForm.php | 73 ++++++++++++++----- 5 files changed, 142 insertions(+), 33 deletions(-) diff --git a/web/modules/custom/riverside_pt/js/calendar.js b/web/modules/custom/riverside_pt/js/calendar.js index 335f6f1..a32b548 100644 --- a/web/modules/custom/riverside_pt/js/calendar.js +++ b/web/modules/custom/riverside_pt/js/calendar.js @@ -47,15 +47,30 @@ panelSlots.innerHTML = ''; arg.allSegs.forEach(function (seg) { const li = document.createElement('li'); - const start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); - const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); - const params = new URLSearchParams({ - start: seg.event.startStr, - end: seg.event.endStr, - }); + const startLabel = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + const endLabel = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); const a = document.createElement('a'); - a.href = drupalSettings.riversidePt.bookingUrl + '?' + params.toString(); - a.textContent = start + ' – ' + end; + 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: seg.event.startStr, + end: seg.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); }); diff --git a/web/modules/custom/riverside_pt/riverside_pt.module b/web/modules/custom/riverside_pt/riverside_pt.module index 0f62ac4..b587820 100644 --- a/web/modules/custom/riverside_pt/riverside_pt.module +++ b/web/modules/custom/riverside_pt/riverside_pt.module @@ -1,5 +1,17 @@ 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; @@ -9,9 +21,15 @@ function riverside_pt_mail(string $key, array &$message, array $params): void { $end = new \DateTime($params['end']); $message['subject'] = 'Booking request — ' . $start->format('M j, Y g:i A'); - $message['body'][] = implode("\n", [ + $lines = [ 'Name: ' . $params['first_name'] . ' ' . $params['last_name'], 'Phone: ' . $params['phone'], 'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '–' . $end->format('g:i A'), - ]); + ]; + + if (!empty($params['comments'])) { + $lines[] = 'Comments: ' . $params['comments']; + } + + $message['body'][] = implode("\n", $lines); } diff --git a/web/modules/custom/riverside_pt/riverside_pt.routing.yml b/web/modules/custom/riverside_pt/riverside_pt.routing.yml index 8c18dd5..a9642f0 100644 --- a/web/modules/custom/riverside_pt/riverside_pt.routing.yml +++ b/web/modules/custom/riverside_pt/riverside_pt.routing.yml @@ -14,6 +14,14 @@ riverside_pt.booking: requirements: _permission: 'access content' +riverside_pt.booking_store_slot: + path: '/schedule/book/slot' + defaults: + _controller: '\Drupal\riverside_pt\Controller\ScheduleController::storeSlot' + requirements: + _permission: 'access content' + methods: [POST] + riverside_pt.schedule_events: path: '/schedule/events' defaults: diff --git a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php index 684eec0..4834834 100644 --- a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php +++ b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php @@ -3,12 +3,25 @@ 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 Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; class ScheduleController extends ControllerBase { + private PrivateTempStore $tempStore; + + public function __construct(PrivateTempStoreFactory $tempStoreFactory) { + $this->tempStore = $tempStoreFactory->get('riverside_pt'); + } + + public static function create(ContainerInterface $container): static { + return new static($container->get('tempstore.private')); + } + public function page(): array { return [ '#type' => 'container', @@ -60,9 +73,10 @@ class ScheduleController extends ControllerBase { 'library' => ['riverside_pt/schedule'], 'drupalSettings' => [ 'riversidePt' => [ - 'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(), - 'bookingUrl' => Url::fromRoute('riverside_pt.booking')->toString(), - 'holidays' => $this->buildHolidaysMap(), + '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(), ], ], ], @@ -78,6 +92,23 @@ class ScheduleController extends ControllerBase { return $map; } + public function storeSlot(Request $request): JsonResponse { + $data = json_decode($request->getContent(), TRUE) ?? []; + $start = $data['start'] ?? ''; + + if (!$start || new \DateTime($start) < new \DateTime()) { + return new JsonResponse(['error' => 'past'], 422); + } + + $this->tempStore->set('booking_slot', [ + 'start' => $start, + 'end' => $data['end'] ?? '', + 'provider_id' => $data['provider_id'] ?? '', + ]); + + return new JsonResponse(['ok' => TRUE]); + } + public function events(Request $request): JsonResponse { $start = $request->query->get('start'); $end = $request->query->get('end'); @@ -98,10 +129,10 @@ class ScheduleController extends ControllerBase { $slot = clone $current; $slot->setTime(9 + $n, 0); $events[] = [ - 'id' => $id++, + 'id' => $id++, 'title' => 'Available', 'start' => $slot->format('Y-m-d\TH:i:s'), - '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'); diff --git a/web/modules/custom/riverside_pt/src/Form/BookingForm.php b/web/modules/custom/riverside_pt/src/Form/BookingForm.php index afd1cec..08a8348 100644 --- a/web/modules/custom/riverside_pt/src/Form/BookingForm.php +++ b/web/modules/custom/riverside_pt/src/Form/BookingForm.php @@ -6,26 +6,29 @@ 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; -use Symfony\Component\HttpFoundation\RequestStack; class BookingForm extends FormBase { + private PrivateTempStore $tempStore; + public function __construct( private readonly MailManagerInterface $mailManager, ConfigFactoryInterface $configFactory, - RequestStack $requestStack, + PrivateTempStoreFactory $tempStoreFactory, ) { $this->configFactory = $configFactory; - $this->requestStack = $requestStack; + $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('request_stack'), + $container->get('tempstore.private'), ); } @@ -34,10 +37,10 @@ class BookingForm extends FormBase { } public function buildForm(array $form, FormStateInterface $form_state): array { - $query = $this->requestStack->getCurrentRequest()->query; - $start = $query->get('start', ''); - $end = $query->get('end', ''); - $uid = $query->get('provider', ''); + $slot = $this->tempStore->get('booking_slot') ?? []; + $start = $slot['start'] ?? ''; + $end = $slot['end'] ?? ''; + $uid = $slot['provider_id'] ?? ''; $slot_display = ''; if ($start && $end) { @@ -46,6 +49,8 @@ class BookingForm extends FormBase { $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'), @@ -60,10 +65,6 @@ class BookingForm extends FormBase { ]; } - $form['start'] = ['#type' => 'hidden', '#value' => $start]; - $form['end'] = ['#type' => 'hidden', '#value' => $end]; - $form['provider_id'] = ['#type' => 'hidden', '#value' => $uid]; - $form['first_name'] = [ '#type' => 'textfield', '#title' => $this->t('First name'), @@ -82,6 +83,12 @@ class BookingForm extends FormBase { '#required' => TRUE, ]; + $form['comments'] = [ + '#type' => 'textarea', + '#title' => $this->t('Comments'), + '#rows' => 4, + ]; + $form['actions'] = ['#type' => 'actions']; $form['actions']['submit'] = [ '#type' => 'submit', @@ -91,19 +98,49 @@ class BookingForm extends FormBase { 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(); - $params = [ + $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'), - 'start' => $form_state->getValue('start'), - 'end' => $form_state->getValue('end'), - ]; - - $sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, $params); + '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.'));