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.
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):**
```bash
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:
This is harmless but noisy. The entrypoint and `_riverside_pt_rebuild()` proactively `TRUNCATE TABLE semaphore` at key points to suppress them. If you ever see them after a manual change, run:
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.
- **FullCalendar 6** — `fullcalendar.min.js` is downloaded by the Dockerfile at build time but the module directory is volume-mounted, so the file must also exist on the host at `js/fullcalendar.min.js` (not gitignored; download once with `curl -fsSL https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js -o web/modules/custom/riverside_pt/js/fullcalendar.min.js`)
`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).
- **Layout**: `.riverside-booking-wrap` is 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 to `storeSlotUrl`, then redirects to `/schedule/book`
- **No popup**: the old backdrop/panel popup was replaced with the inline side panel
Spacer divs with `basis-[x%] grow-[n]` control the offset columns on desktop. On mobile (`<sm`),spacersare`hidden`andcontentgoesfull-widthwithagradientoverlayforlegibility.At`2xl`,thesectionswitchestoatrueside-by-sidesplitwiththetextonasolidtealbackground.
`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.
- If `DEBUG` (dev/localhost): force `system.mail.interface.default = 'php_mail'` (so it uses the mocked sendmail_path below) and never use Postmark.
- Else if `POSTMARK_API_KEY` present (prod): set `mailer_transport.settings.default_transport = postmark` and `system.mail.interface.default = symfony_mailer`.
On localhost/dev (when `DEBUG` is truthy), a fake sendmail binary is provided (via Dockerfile + entrypoint) **only in DEBUG mode** 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 (non-DEBUG).