- Add required email input (with autocomplete="email") to the booking
details form in the homepage Preact widget (rpt-booking.js).
Update EMPTY_FORM, submit payload, confirmedAppointment, and success
summary to include it. The form now collects: first/last name, email,
phone, comments.
- Update ScheduleController::storeSlot to extract/pass email in the
booking_request mail params (and require it for the full-contact path).
Log failures with details; return a structured error with a user-friendly
message instead of bare "mail_failed".
- riverside_pt_mail hook now includes the user's email in the notification
body (when provided).
- Dev improvements for mail:
- In DEBUG mode (default on localhost), force php_mail interface in
settings.php so the mailer uses the sendmail_path override.
- Dockerfile + entrypoint.sh now provide/install a fake-sendmail.sh
that prints the full email (To, Subject, headers, body from the
hook) to stderr (visible in `docker compose logs`) and always
succeeds (exit 0). This prevents "sh: 1: /usr/sbin/sendmail: not
found" and guarantees booking submissions never return the
"unable to send confirmation email" error in dev.
- In non-DEBUG, still uses symfony_mailer + Postmark as before.
- The fake is also baked into the image for consistency.
- JS error handling now prefers the server-provided 'message' from
the JSON error response (better UX for real mail failures).
- Update CLAUDE.md with the new email field + dev mail mocking behavior.
- New file: docker/php/fake-sendmail.sh (the mock).
This addresses the recent "mail_failed" issues while keeping production
email via Postmark.
9.8 KiB
Riverside Physical Therapy — Project Context
What this is
A Drupal 11 site for Riverside Physical Therapy. Nearly all frontend work lives in a single custom module (riverside_pt) rather than a Drupal theme. The site runs in Docker with nginx + php-fpm + PostgreSQL.
Running locally
docker compose up # starts app on http://localhost:8080 (full DB wipe + rebuild from code by default)
docker compose exec app drush cr # clear Drupal cache
npm run watch # Tailwind CSS watcher (run on host, not in container)
npm run build # minified production build
Database & site rebuild behavior
By default, every docker compose up performs a full database wipe followed by a complete reinstall + rebuild of the site structure from code:
- Drops the database
- Runs
drush site:install standard - Enables modules (including
riverside_pt) - Runs
drush riverside:rebuild(the single source of truth for content types, fields, roles, and navigation)
This means the site is always built exactly the same way from the code in riverside_pt.install and the Drush command. There is no persistent data between restarts unless you opt out.
Faster iteration (preserve the database):
DRUPAL_FAST=1 docker compose up
This skips the wipe + site:install but still runs drush riverside:rebuild and the rest of the startup steps. Use this when you want quicker starts during active development and don't need a completely clean slate.
You can also run the rebuild manually at any time:
docker compose exec app drush riverside:rebuild
# or the short alias
docker compose exec app drush rrb
The custom module directory is volume-mounted, so template/CSS/JS edits are live without rebuilding the Docker image. settings.php and development.services.yml are also volume-mounted.
Known gotcha: drush site:install rewrites settings.php. Because settings.php is a bind-mounted file, Docker Desktop on macOS may hold a stale inode reference after the rewrite. If Drupal shows "The provided host name is not valid for this server" after a full rebuild, restart with DRUPAL_FAST=1 to re-establish the mount without re-running site:install.
Stack
- Drupal 11 (core_version_requirement: ^11)
- PHP 8.5-fpm (in Docker)
- PostgreSQL 18 (db: drupal, user: drupal, pass: drupal)
- Tailwind CSS v3 — compiled on the host via npm, output to
app.css - FullCalendar 6 —
fullcalendar.min.jsis downloaded by the Dockerfile at build time but the module directory is volume-mounted, so the file must also exist on the host atjs/fullcalendar.min.js(not gitignored; download once withcurl -fsSL https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js -o web/modules/custom/riverside_pt/js/fullcalendar.min.js) - Postmark — email via
drupal/symfony_mailer - Preact 10 + htm — loaded via esm.sh CDN imports inside each custom element JS file; no build step required
Custom module: riverside_pt
All site-specific code lives here: web/modules/custom/riverside_pt/
Templates
| File | Purpose |
|---|---|
templates/riverside-pt-header.html.twig |
Fixed top nav with hamburger on mobile |
templates/riverside-pt-home.html.twig |
Home page: hero, services, mission, testimonials, booking, FAQ |
The header is injected globally via riverside_pt_page_top() in .module, not rendered by a controller.
Controllers / routes
| Route | Path | Controller / Form |
|---|---|---|
riverside_pt.home |
/home |
HomeController::page |
riverside_pt.schedule |
/schedule |
ScheduleController::page |
riverside_pt.booking |
/schedule/book |
BookingForm |
riverside_pt.booking_store_slot |
/schedule/book/slot (POST) |
ScheduleController::storeSlot |
riverside_pt.schedule_events |
/schedule/events |
ScheduleController::events |
HomeController::page attaches the schedule library and passes drupalSettings.riversidePt (eventsUrl, bookingUrl, storeSlotUrl, holidays) so the booking calendar on the home page works identically to the /schedule page.
ScheduleController::events generates mock availability Mon–Fri only (skips Sat/Sun via N > 5 day-of-week check).
CSS / JS
| File | Purpose |
|---|---|
css/tailwind.css |
Tailwind entry point (@tailwind base/components/utilities + custom layers) |
css/app.css |
Compiled Tailwind output — do not edit directly |
css/calendar.css |
FullCalendar overrides + booking layout + slot button styles |
js/nav.js |
Hamburger toggle — adds/removes is-open on #rpt-main-nav |
js/calendar.js |
FullCalendar init: circle-per-day availability, dateClick → inline slot grid, auto-selects next business day on load |
js/components/rpt-toggle.js |
Generic toggle button web component |
js/components/rpt-carousel.js |
Facility photo carousel (used on /schedule) |
js/components/rpt-testimonials.js |
Testimonials carousel — pixel-offset scroll, pointer drag, swipe, resize debounce |
js/components/rpt-faq.js |
Accordion FAQ section |
js/components/rpt-appt-type.js |
Appointment type selector (2×2 card grid, pre-selects Diagnostic Assessment) |
Custom element pattern
All interactive components are Preact web components following this pattern:
import { h, render } from "https://esm.sh/preact@10";
import { useState } from "https://esm.sh/preact@10/hooks";
import { html } from "https://esm.sh/htm@3/preact";
class RptFoo extends HTMLElement {
connectedCallback() { render(html`<${Foo} />`, this); }
disconnectedCallback() { render(null, this); }
}
customElements.define("rpt-foo", RptFoo);
Use double-quoted strings only in these files — the esm.sh CDN fetch and browser JS parsing will fail silently on curly/smart quotes.
Libraries
Defined in riverside_pt.libraries.yml:
riverside_pt/app—app.css+nav.js+ alljs/components/*.js(astype: module), attached globally viariverside_pt_page_attachments()riverside_pt/schedule—calendar.css+fullcalendar.min.js+calendar.js+core/drupalSettings
Home page sections (in order)
- Hero — two-layer flex layout (image absolute, text relative); 2xl breakpoint adds a side-by-side split with solid teal panel
- Services grid — 4-column card grid (
xl:grid-cols-4) - Mission / stats —
bg-[#dde8f0],max-w-[1200px]content container with neck.jpg image - Testimonials —
<rpt-testimonials>carousel; cards overflow right of amax-w-[1200px]safe area; pixel-offsetposition:relative; leftanimation; pointer drag + swipe - Book An Appointment —
<rpt-appt-type>selector above the FullCalendar booking widget; slots display inline beside the calendar - FAQ —
<rpt-faq>accordion;grid-template-rows: 0fr → 1frexpand animation
Booking calendar details
- Layout:
.riverside-booking-wrapis a flex row (stacks on mobile) — calendar left, slot grid right - Auto-selection: on load, calendar navigates to the next business day; if that day has events, it is pre-selected and the first slot is highlighted
- Slot interaction: clicking a slot highlights it (
.riverside-slot-btn.is-selected) and immediately POSTs tostoreSlotUrl, then redirects to/schedule/book - No popup: the old backdrop/panel popup was replaced with the inline side panel
--cal-row-h: CSS custom property on#riverside-calendarcontrols row height; frame usescalc(var(--cal-row-h) - 0.5rem)
Tailwind notes
- Config scans
templates/**/*.twigandsrc/**/*.phpfor class names - Breakpoints are standard Tailwind v3:
sm= 640px,md= 768px,2xl= 1536px - Mobile nav collapse (
max-heightslide) is intailwind.cssunder@layer componentsbecause it can't be expressed with utilities - Arbitrary Tailwind values use
_for spaces:bg-[#4a7a8a],shadow-[-56px_2px_10px_#0000001A] - Non-standard CSS properties (e.g.
text-shadow) use arbitrary property syntax:[text-shadow:...]
Hero section layout technique
The hero uses two overlapping flex rows inside a relative section:
- Box 1 (
absolute inset-0 flex): image layer, out of flow, fills the section - Box 2 (
relative flex min-h-[480px]): text layer, in flow, sets section height
Spacer divs with basis-[x%] grow-[n] control the offset columns on desktop. On mobile (< sm), spacers are hidden and content goes full-width with a gradient overlay for legibility. At 2xl, the section switches to a true side-by-side split with the text on a solid teal background.
Development services
web/sites/development.services.yml is loaded when DEBUG=true (set in docker-compose). It enables Twig debug/auto-reload and defines cache.backend.null (used by settings.php to disable render/page caching in dev).
Menu / navigation
Nav items come from Drupal's main menu. Items titled "Book An Appointment" or "Contact" are flagged is_cta: true in the preprocess hook and rendered as a CTA button in the header template.
Booking confirmation emails are sent via riverside_pt_mail() in .module using the booking_request key. Transport is Postmark (via drupal/symfony_mailer), configured in config/sync/symfony_mailer.mailer_transport.postmark.yml. The API key is injected via the POSTMARK_API_KEY environment variable.
In settings.php (when the key is present) we also force:
mailer_transport.settings.default_transport = postmarksystem.mail.interface.default = symfony_mailer
On localhost/dev, a fake sendmail binary is provided (via Dockerfile + entrypoint) that prints the full email to stderr (visible via docker compose logs) instead of failing with "sh: 1: /usr/sbin/sendmail: not found". This catches any legacy php_mail fallbacks. Real transactional mail goes through Postmark when configured.