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:
parent
e3c2e3e3a1
commit
1b7577fa17
13 changed files with 188 additions and 263 deletions
|
|
@ -741,6 +741,14 @@ html {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mt-1{
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-3{
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.block{
|
.block{
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
@ -1086,6 +1094,11 @@ html {
|
||||||
border-color: rgb(255 255 255 / 0.6);
|
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{
|
.bg-current{
|
||||||
background-color: currentColor;
|
background-color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
@ -1138,6 +1151,11 @@ html {
|
||||||
background-color: rgb(255 255 255 / 0.9);
|
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{
|
.bg-gradient-to-b{
|
||||||
background-image: linear-gradient(to bottom, var(--tw-gradient-stops));
|
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));
|
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{
|
.no-underline{
|
||||||
text-decoration-line: none;
|
text-decoration-line: none;
|
||||||
}
|
}
|
||||||
|
|
@ -1593,6 +1625,11 @@ html {
|
||||||
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
|
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{
|
.focus\:border-pt-blue-500:focus{
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(48 111 142 / var(--tw-border-opacity, 1));
|
border-color: rgb(48 111 142 / var(--tw-border-opacity, 1));
|
||||||
|
|
@ -1619,6 +1656,10 @@ html {
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px){
|
@media (min-width: 768px){
|
||||||
|
.sm\:col-span-2{
|
||||||
|
grid-column: span 2 / span 2;
|
||||||
|
}
|
||||||
|
|
||||||
.sm\:block{
|
.sm\:block{
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,13 +73,6 @@
|
||||||
color: #111;
|
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-group,
|
||||||
#riverside-calendar .fc-button {
|
#riverside-calendar .fc-button {
|
||||||
background: none !important;
|
background: none !important;
|
||||||
|
|
@ -179,6 +172,11 @@
|
||||||
background: none !important;
|
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 */
|
/* Weekends — same appearance as any other non-available day */
|
||||||
#riverside-calendar .fc-day-sat,
|
#riverside-calendar .fc-day-sat,
|
||||||
#riverside-calendar .fc-day-sun {
|
#riverside-calendar .fc-day-sun {
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
fixedWeekCount: false,
|
fixedWeekCount: false,
|
||||||
|
showNonCurrentDates: false,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
events: buildEventsUrl(currentService),
|
events: buildEventsUrl(currentService),
|
||||||
eventDisplay: 'none',
|
eventDisplay: 'none',
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
<polyline points="1,5.5 5,9.5 13,1" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
const EMPTY_FORM = { lastName: "", phone: "", comments: "" };
|
const EMPTY_FORM = { firstName: "", lastName: "", phone: "", comments: "" };
|
||||||
|
|
||||||
function formatPhone(raw) {
|
function formatPhone(raw) {
|
||||||
let d = String(raw || "").replace(/\D/g, "");
|
let d = String(raw || "").replace(/\D/g, "");
|
||||||
|
|
@ -57,6 +57,8 @@ function Booking({ settings }) {
|
||||||
const [formData, setFormData] = useState(EMPTY_FORM);
|
const [formData, setFormData] = useState(EMPTY_FORM);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState(null);
|
const [submitError, setSubmitError] = useState(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [noSlotsInMonth, setNoSlotsInMonth] = useState(false);
|
||||||
|
|
||||||
const calEl = useRef(null);
|
const calEl = useRef(null);
|
||||||
const calRef = useRef(null);
|
const calRef = useRef(null);
|
||||||
|
|
@ -111,6 +113,7 @@ function Booking({ settings }) {
|
||||||
d.classList.remove("is-selected");
|
d.classList.remove("is-selected");
|
||||||
});
|
});
|
||||||
setSelectedSlotId(null);
|
setSelectedSlotId(null);
|
||||||
|
setNoSlotsInMonth(false);
|
||||||
if (selectedDate) {
|
if (selectedDate) {
|
||||||
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]");
|
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDate + "\"]");
|
||||||
if (dayEl) {
|
if (dayEl) {
|
||||||
|
|
@ -148,6 +151,12 @@ function Booking({ settings }) {
|
||||||
cal.next();
|
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) {
|
dayCellClassNames: function (arg) {
|
||||||
|
|
@ -168,6 +177,7 @@ function Booking({ settings }) {
|
||||||
selectedDateSlots = daySlots;
|
selectedDateSlots = daySlots;
|
||||||
setSelectedSlotId(null);
|
setSelectedSlotId(null);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
setSuccess(false);
|
||||||
setSlots(daySlots);
|
setSlots(daySlots);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -193,6 +203,8 @@ function Booking({ settings }) {
|
||||||
setSelectedSlotId(null);
|
setSelectedSlotId(null);
|
||||||
setFormData(EMPTY_FORM);
|
setFormData(EMPTY_FORM);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
setNoSlotsInMonth(false);
|
||||||
cal.gotoDate(initDate);
|
cal.gotoDate(initDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,6 +215,7 @@ function Booking({ settings }) {
|
||||||
function handleSlotClick(slot) {
|
function handleSlotClick(slot) {
|
||||||
setSelectedSlotId(slot.id);
|
setSelectedSlotId(slot.id);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
setSuccess(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFormChange(field, value) {
|
function handleFormChange(field, value) {
|
||||||
|
|
@ -222,17 +235,26 @@ function Booking({ settings }) {
|
||||||
start: slot.startStr,
|
start: slot.startStr,
|
||||||
end: slot.endStr,
|
end: slot.endStr,
|
||||||
service: service,
|
service: service,
|
||||||
|
firstName: formData.firstName,
|
||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
phone: formData.phone,
|
phone: formData.phone,
|
||||||
comments: formData.comments,
|
comments: formData.comments,
|
||||||
}),
|
}),
|
||||||
}).then(function (res) {
|
}).then(function (res) {
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
window.location.href = settings.bookingUrl;
|
setSubmitting(false);
|
||||||
|
setSubmitError(null);
|
||||||
|
setSuccess(true);
|
||||||
|
setSelectedSlotId(null);
|
||||||
|
setFormData(EMPTY_FORM);
|
||||||
} else {
|
} else {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
if (res.status === 422) {
|
||||||
|
setSubmitError("That slot was just booked. Please choose another time.");
|
||||||
|
} else {
|
||||||
setSubmitError("Something went wrong. Please try again.");
|
setSubmitError("Something went wrong. Please try again.");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setSubmitError("Something went wrong. Please try again.");
|
setSubmitError("Something went wrong. Please try again.");
|
||||||
|
|
@ -280,7 +302,16 @@ function Booking({ settings }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="riverside-booking-wrap">
|
<div class="riverside-booking-wrap">
|
||||||
|
<div style="position:relative">
|
||||||
<div ref=${calEl} id="riverside-calendar"></div>
|
<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`
|
${slots.length > 0 ? html`
|
||||||
<div id="riverside-slots-wrap">
|
<div id="riverside-slots-wrap">
|
||||||
<div id="riverside-booking-slots">
|
<div id="riverside-booking-slots">
|
||||||
|
|
@ -299,6 +330,28 @@ function Booking({ settings }) {
|
||||||
` : null}
|
` : null}
|
||||||
</div>
|
</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`
|
${selectedSlot ? html`
|
||||||
<form
|
<form
|
||||||
onSubmit=${handleSubmit}
|
onSubmit=${handleSubmit}
|
||||||
|
|
@ -309,6 +362,18 @@ function Booking({ settings }) {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5 mb-5">
|
<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>
|
<div>
|
||||||
<label class=${labelClass}>
|
<label class=${labelClass}>
|
||||||
Last name <span class="text-red-500">*</span>
|
Last name <span class="text-red-500">*</span>
|
||||||
|
|
@ -321,7 +386,7 @@ function Booking({ settings }) {
|
||||||
class=${inputClass}
|
class=${inputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:col-span-2">
|
||||||
<label class=${labelClass}>
|
<label class=${labelClass}>
|
||||||
Phone number <span class="text-red-500">*</span>
|
Phone number <span class="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const FAQS = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "Can I book an appointment online?",
|
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?",
|
q: "What should I wear or bring to my session?",
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ function _riverside_pt_build_navigation(): void {
|
||||||
['title' => 'FAQ', 'uri' => 'internal:/faq', 'weight' => 3, 'class' => NULL],
|
['title' => 'FAQ', 'uri' => 'internal:/faq', 'weight' => 3, 'class' => NULL],
|
||||||
|
|
||||||
['title' => 'Contact', 'uri' => 'internal:/contact', 'weight' => 4, 'class' => 'nav-cta nav-cta--primary'],
|
['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) {
|
foreach ($defs as $def) {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ schedule:
|
||||||
css/calendar.css: {}
|
css/calendar.css: {}
|
||||||
js:
|
js:
|
||||||
js/fullcalendar.min.js: { minified: true }
|
js/fullcalendar.min.js: { minified: true }
|
||||||
js/calendar.js: {}
|
|
||||||
js/components/rpt-booking.js: { attributes: { type: module } }
|
js/components/rpt-booking.js: { attributes: { type: module } }
|
||||||
dependencies:
|
dependencies:
|
||||||
- core/drupalSettings
|
- core/drupalSettings
|
||||||
|
|
|
||||||
|
|
@ -74,14 +74,6 @@ function riverside_pt_preprocess_riverside_pt_header(array &$variables): void {
|
||||||
$variables['current_path'] = \Drupal::request()->getPathInfo();
|
$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 {
|
function riverside_pt_mail(string $key, array &$message, array $params): void {
|
||||||
if ($key !== 'booking_request') {
|
if ($key !== 'booking_request') {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -14,22 +14,6 @@ riverside_pt.home:
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_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:
|
riverside_pt.booking_store_slot:
|
||||||
path: '/schedule/book/slot'
|
path: '/schedule/book/slot'
|
||||||
defaults:
|
defaults:
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ class HomeController extends ControllerBase {
|
||||||
'drupalSettings' => [
|
'drupalSettings' => [
|
||||||
'riversidePt' => [
|
'riversidePt' => [
|
||||||
'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
|
'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
|
||||||
'bookingUrl' => Url::fromRoute('riverside_pt.booking')->toString(),
|
|
||||||
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
|
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
|
||||||
'holidays' => $holidayMap,
|
'holidays' => $holidayMap,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ namespace Drupal\riverside_pt\Controller;
|
||||||
use Drupal\Core\Controller\ControllerBase;
|
use Drupal\Core\Controller\ControllerBase;
|
||||||
use Drupal\Core\TempStore\PrivateTempStore;
|
use Drupal\Core\TempStore\PrivateTempStore;
|
||||||
use Drupal\Core\TempStore\PrivateTempStoreFactory;
|
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\DependencyInjection\ContainerInterface;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
@ -13,66 +14,23 @@ use Symfony\Component\HttpFoundation\Request;
|
||||||
class ScheduleController extends ControllerBase {
|
class ScheduleController extends ControllerBase {
|
||||||
|
|
||||||
private PrivateTempStore $tempStore;
|
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->tempStore = $tempStoreFactory->get('riverside_pt');
|
||||||
|
$this->configFactory = $configFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function create(ContainerInterface $container): static {
|
public static function create(ContainerInterface $container): static {
|
||||||
return new static($container->get('tempstore.private'));
|
return new static(
|
||||||
}
|
$container->get('tempstore.private'),
|
||||||
|
$container->get('plugin.manager.mail'),
|
||||||
public function page(): array {
|
$container->get('config.factory'),
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function storeSlot(Request $request): JsonResponse {
|
public function storeSlot(Request $request): JsonResponse {
|
||||||
|
|
@ -83,14 +41,61 @@ class ScheduleController extends ControllerBase {
|
||||||
return new JsonResponse(['error' => 'past'], 422);
|
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', [
|
$this->tempStore->set('booking_slot', [
|
||||||
'start' => $start,
|
'start' => $start,
|
||||||
'end' => $data['end'] ?? '',
|
'end' => $end,
|
||||||
'service' => $data['service'] ?? 'diagnostic',
|
'service' => $service,
|
||||||
'last_name' => $data['lastName'] ?? '',
|
'last_name' => $lastName,
|
||||||
'phone' => $data['phone'] ?? '',
|
'phone' => $phone,
|
||||||
'comments' => $data['comments'] ?? '',
|
'comments' => $comments,
|
||||||
'provider_id' => $data['provider_id'] ?? '',
|
'provider_id' => $providerId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new JsonResponse(['ok' => TRUE]);
|
return new JsonResponse(['ok' => TRUE]);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
<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]">
|
<div class="flex gap-4 flex-wrap items-center mt-[2vw]">
|
||||||
<a
|
<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"
|
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>
|
>Book An Appointment</a>
|
||||||
<a
|
<a
|
||||||
|
|
@ -115,7 +115,7 @@
|
||||||
<rpt-testimonials class="block"></rpt-testimonials>
|
<rpt-testimonials class="block"></rpt-testimonials>
|
||||||
</section>
|
</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">
|
<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>
|
<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>
|
<rpt-booking class="block"></rpt-booking>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue