Compare commits

...

57 commits

Author SHA1 Message Date
Philip Peterson
d7a95529cb use ci
Some checks failed
Build and push image / build (push) Failing after 12s
CI / build (push) Failing after 2s
CI / bump-infra (push) Has been skipped
2026-06-08 19:21:54 -07:00
Philip Peterson
0196733be8 scroll 2026-06-08 19:14:22 -07:00
Philip Peterson
32c6b6dd5a add flood control 2026-06-06 00:17:08 -07:00
Philip Peterson
871ac5b3ef fix 2026-06-06 00:13:21 -07:00
Philip Peterson
d4300cf04e Consolidate classes 2026-06-05 02:01:03 -07:00
Philip Peterson
8d099b220f Fixes 2026-06-05 01:54:26 -07:00
Philip Peterson
7716e7e26e Switch to postmark+smtp transport to avoid header restrictions
Postmark's API transport rejects headers like Return-Path that Drupal adds
automatically. The SMTP relay is more permissive and avoids this class of
rejection entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:39:28 -07:00
Philip Peterson
bc8559a3c7 Fix Postmark mail transport by using core mailer_dsn config
Drupal core's symfony_mailer plugin reads system.mail.mailer_dsn directly
from settings.php — the mailer_transport module entity system is only a UI
layer and is not consulted when actually sending mail. Removed the
mailer_transport entity setup from the entrypoint and configured the DSN
correctly via $config overrides in settings.php instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 00:37:56 -07:00
Philip Peterson
9193e54e87 wip 2026-06-05 00:30:25 -07:00
Philip Peterson
69e6ef3e8b fixes 2026-06-05 00:04:37 -07:00
Philip Peterson
27e2b6f2d9 have Drupal forward logs to actual logs 2026-06-04 23:22:43 -07:00
Philip Peterson
642dee3c1f wip 2026-06-04 23:05:25 -07:00
Philip Peterson
bb84ca34a8 fixes 2026-06-04 22:52:41 -07:00
Philip Peterson
39ced1a5af Fixes 2026-06-04 00:15:22 -07:00
Philip Peterson
9bbb7712fe Fix FAQ 2026-06-04 00:13:32 -07:00
Philip Peterson
8a96b526f1 Slugs 2026-06-04 00:10:57 -07:00
Philip Peterson
1e3ba132a4 Suppress duplicate key errors on semaphore table during rebuilds
- In _riverside_pt_rebuild(): proactively TRUNCATE the semaphore table
  at the very start of every rebuild. This eliminates the common
  'duplicate key value violates unique constraint "semaphore____pkey"'
  errors for 'state:Drupal\Core\Cache\CacheCollector' and 'cron' that
  appear in postgres logs.

- In entrypoint.sh: add TRUNCATE semaphore at strategic points
  (right after site:install, before module enables, before/after
  riverside:rebuild, before final drush cr). Wrapped with || true
  so they never break the startup script.

- Added a note in CLAUDE.md under the rebuild section explaining
  the errors and the quick manual fix.

These are harmless (Drupal's DbLockBackend usually recovers) but
very noisy in the container logs during the default full rebuild
path.
2026-06-04 00:06:27 -07:00
Philip Peterson
4d895d1b0d Use page routing 2026-06-04 00:05:52 -07:00
Philip Peterson
60ecacb4d4 Fix scrolling 2026-06-04 00:02:27 -07:00
Philip Peterson
797e580cc0 Create custom /contact page with details and appointment CTAs
- Refactor AboutController to PageController to handle multiple static pages:
  - /about
  - /services/{slug} (diagnostic, sports, pre-post, neuro)
  - /contact (new)

- New template riverside-pt-contact.html.twig:
  - Contact details (address, phone, email)
  - Office hours
  - 'Send us a message' section directing to booking tool
  - Multiple 'Make an Appointment' links back to /home#book-an-appointment

- Updated riverside_pt.routing.yml with riverside_pt.contact route
- Registered 'riverside_pt_contact' theme in riverside_pt.module
- Updated riverside_pt.install to skip legacy node creation for 'Contact' (and previously About/Services) to avoid alias conflicts
- Minor updates to home template, header handling, libraries (scroll support), and other controllers for consistency with page flows and email/booking features

All details pages (/about, /services/*, /contact) now include clear links back to 'Make an Appointment'.
2026-06-03 23:55:02 -07:00
Philip Peterson
95a0a3e004 Use labels 2026-06-03 23:33:09 -07:00
Philip Peterson
6879b056da Use sendmail only on dev 2026-06-03 23:30:44 -07:00
Philip Peterson
96eafc4f5c Dont autoselect service type 2026-06-03 23:16:40 -07:00
Philip Peterson
26537efa94 Fix CSS 2026-06-03 23:08:02 -07:00
Philip Peterson
2dd6c7da22 Add email field to booking form + dev mail mocking
- 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.
2026-06-03 23:05:06 -07:00
Philip Peterson
5293f3f347 Fixes to calendar lifecycle 2026-06-03 22:47:31 -07:00
Philip Peterson
59b7e57b5e Remount for service 2026-06-03 22:36:45 -07:00
Philip Peterson
f882149a37 Fix for race condition 2026-06-03 22:21:43 -07:00
Philip Peterson
8962fc5f0e Smooth scroll, booking refactor, success summary
- Add scroll.js: data-scroll-to attribute drives smooth scrollIntoView;
  scroll-margin-top at md+ accounts for fixed header offset
- Wire Services, FAQ, Book An Appointment, View Our Services nav/hero
  links to on-page anchors; don't close hamburger on scroll-link clicks
- Refactor booking calendar: own the fetch (useEffect + dateRange state)
  instead of handing URL to FullCalendar; removes fetchedRef complexity;
  noSlotsInMonth derived cleanly from fetchLoading + fetchedEvents
- Success state shows appointment summary (name, service, date/time);
  hides calendar/form on success; no "book another" button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 22:14:39 -07:00
Philip Peterson
9e1e6a57b7 Hoist Tailwind classes to CX object; fix no-slots overlay flash
- Move all Tailwind class strings to a module-level CX constant so the
  JIT scanner sees complete literals in one place rather than scattered
  across template expressions
- Convert no-slots overlay and wrapper from inline styles to CX entries
  (adds z-10 to fix stacking above FullCalendar grid)
- Fix no-availability message flashing on month navigation: reset
  fetchedRef in datesSet so eventsSet ignores stale pre-fetch firings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 21:20:04 -07:00
Philip Peterson
1b7577fa17 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>
2026-06-03 20:51:43 -07:00
Philip Peterson
e3c2e3e3a1 Add nice non-restrictive phone number input formatting for booking
- New pure-vanilla progressive formatter that turns typed digits into
  (123) 456-7890 (or 1 (800) 555-1212 for +1 numbers) on the fly.
- Works for free-form input: typing, pasting, editing anywhere, backspacing,
  extra chars, etc. — no hard mask or input restrictions.
- Applied to:
  - The Preact <rpt-booking> widget phone field (home page quick booking + details).
  - The final Drupal confirmation form at /schedule/book (via lightweight
    enhancer + .rpt-phone class).
- Prefills last_name / phone / comments on the confirmation form when the
  widget was used to pick the slot (so collected phone isn't lost).
- No new dependencies whatsoever:
  * Zero npm / package.json / composer changes.
  * Zero additional CDN / external scripts (uses native String.replace,
    regex, and input event + selection APIs only).
  * The new js/phone-format.js is simply attached via the pre-existing
    'app' library (already loaded on all riverside_pt.* routes).
  * Formatter logic duplicated in the ESM component (tiny pure function).
- The two other modified files (calendar.css, calendar.js) were left
  uncommitted as they are unrelated to this feature.
2026-06-03 20:35:40 -07:00
Philip Peterson
58988a4fe8 Fix calendar auto-advance and add fault injection for availability
- Remove client-side serviceEarliestDate; instead auto-advance month by
  month until the server returns events (capped at 12 months)
- Only mark initialized when a date is actually found, so empty months
  don't block auto-select on subsequent fetches
- Add per-service fault injection flags in ScheduleController::events
  to force zero availability for testing the no-slots UI path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:52:14 -07:00
Philip Peterson
b000b824ed Service-aware booking: selector drives calendar, inline request form
- Merge rpt-appt-type + calendar into unified rpt-booking Preact component;
  service state drives event source via useEffect, no DOM event bus
- Each service has distinct availability (different slot density, start hours);
  surgical rehab only available 46+ days out
- Slot click reveals inline Last name / Phone / Comments form; submits all
  fields together to storeSlot rather than redirecting immediately
- Fix nextBusinessDay() timezone bug (toISOString is UTC; use local date
  components instead); pre-select first available day >= next business day
- Today always has no availability (backend now starts from tomorrow)
- Replace all raw hex colour values with named palette tokens throughout
  templates and JS components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 19:39:35 -07:00
Philip Peterson
2f624f73ba Fix colors 2026-06-03 19:12:53 -07:00
Philip Peterson
e9bce5aac8 Fix dragging issues 2026-06-03 02:31:16 -07:00
Philip Peterson
84d47728f9 Update CLAUDE.md 2026-06-03 02:14:42 -07:00
Philip Peterson
50f14afcd1 Appointment type selector 2026-06-03 02:13:06 -07:00
Philip Peterson
c073984e82 Redesign booking calendar: inline slots, M-F only, pre-select next day
- Slots panel moves from popup to side-by-side with the calendar
- First available slot is pre-selected (highlighted) on load
- Calendar initializes to the next business day
- eventsSet auto-selects that date if it has availability
- Events endpoint now skips Sat/Sun (N > 5)
- Removed backdrop/close-button popup infrastructure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 02:02:56 -07:00
Philip Peterson
b7287e8076 Clean up carousel: remove debug border, drop unused wrapRef
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:56:07 -07:00
Philip Peterson
d56e94ad09 Remove leftRef now that forceUpdate made it redundant
Resize handler uses functional setLeft (no direct left read).
onPointerDown uses left from render closure directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:54:48 -07:00
Philip Peterson
bccac386a7 Fix carousel: swipe snap, disabled state, pointer events, resize
- Snap clamps after rounding so it can't overshoot max on narrow viewports
- atEnd computed inline each render from live measureMax()
- forceUpdate on resize guarantees re-render even when left doesn't change
- Track gets explicit width (TOTAL_W) so gap areas are hit-testable
- Resize handler always calls setLeft (clamp or no-op) to keep state consistent

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:54:29 -07:00
Philip Peterson
d693e87e02 Simplify swipe: direction-detect on touchend only
Drop live drag tracking, rubber-band resistance, and dragging ref.
A swipe > 50px left/right calls next()/prev() — same result, far less code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:41:13 -07:00
Philip Peterson
52af78a31a Add touch/swipe support to testimonials carousel
Live drag tracks the finger with transition disabled; slight rubber-band
resistance at both edges. On touchend, snaps to the nearest card
boundary within [−maxLeft, 0].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:40:45 -07:00
Philip Peterson
b41113f318 Fix resize handler: use ref to avoid stale closure and listener churn
Register resize listener once with empty deps; track left via ref so
the debounced callback always reads the current offset without
re-registering on every click.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:40:15 -07:00
Philip Peterson
b0eaca462f Add testimonials carousel, redesign calendar, fix trusted host
- rpt-testimonials: pixel-offset carousel with DOM-measured max scroll,
  cards overflow the 1200px safe area to the right; red debug border on container
- calendar: circle-per-day design replacing event bars; teal outline for
  available days, filled for selected; dateClick replaces moreLinkClick
- settings.php: normalize BASE_URL before parse_url to fix trusted host
  error when scheme is missing; always include localhost fallback patterns

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 01:37:13 -07:00
Philip Peterson
187174caa6 fix 2026-06-01 03:21:54 -07:00
Philip Peterson
0d35dda628 Fix palette swatches, login styling, and login redirect
- PaletteController: render proper color swatch cards (box + label) and
  wrap output in Markup::create() so Drupal's XSS filter doesn't strip
  inline style attributes
- riverside_pt.module: scope page_attachments and page_top to
  riverside_pt.* routes only — Tailwind preflight was blowing away
  Drupal's default form styles on the login page
- settings.php: derive trusted_host_patterns from BASE_URL so the host
  and port always agree; prevents localhost:8080 being treated as untrusted
- entrypoint.sh: pass --base-url to drush site:install so Drupal stores
  the correct canonical URL from the start

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 03:00:19 -07:00
Philip Peterson
1cb8335158 add swatch page, rename colors 2026-06-01 02:46:47 -07:00
Philip Peterson
21d4174c8f wip 2026-06-01 02:21:51 -07:00
Philip Peterson
1d9b1c1625 functionality build out 2026-06-01 01:47:26 -07:00
Philip Peterson
8c5ce93d79 Fix statelessness 2026-05-28 18:14:59 -07:00
Philip Peterson
d98f9a9efd Fixes 2026-05-28 00:07:02 -07:00
Philip Peterson
91b6b3af89 Fix styling 2026-05-27 23:42:59 -07:00
Philip Peterson
ba4b140d94 Adjust breakpoints to standard sm/md/lg for clearer mobile/tablet/desktop; minor hero spacing tweaks 2026-05-27 22:44:10 -07:00
Philip Peterson
2e5425606d Style tweaks 2026-05-27 22:28:12 -07:00
Philip Peterson
cd2d59f298 Tweak lifecycle to make more stateless, fix some styling 2026-05-27 21:58:23 -07:00
43 changed files with 2819 additions and 2370 deletions

60
.gitea/workflows/ci.yml Normal file
View file

@ -0,0 +1,60 @@
name: CI
on:
push:
branches: [main]
env:
REGISTRY: forge.quinefoundation.com
IMAGE: forge.quinefoundation.com/cold-air-networks/customer-riverside
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to registry
run: |
echo "${{ secrets.FORGE_TOKEN }}" \
| docker login "$REGISTRY" -u "${{ secrets.FORGE_USER }}" --password-stdin
- name: Build and push
run: |
docker build -t "$IMAGE:${{ github.sha }}" .
docker push "$IMAGE:${{ github.sha }}"
bump-infra:
needs: build
runs-on: ubuntu-latest
steps:
- name: Bump riverside in infra repo
env:
FORGE_TOKEN: ${{ secrets.FORGE_TOKEN }}
FORGE_USER: ${{ secrets.FORGE_USER }}
SHA: ${{ github.sha }}
run: |
git clone "https://${FORGE_USER}:${FORGE_TOKEN}@forge.quinefoundation.com/Cold-Air-Networks/petersweb-infra.git" infra
cd infra
BRANCH="bump-riverside-${SHA:0:7}"
git checkout -b "$BRANCH"
./bump-riverside.sh "sha:$SHA"
git config user.email "ci@quinefoundation.com"
git config user.name "CI"
git add -A
git commit -m "bump riverside to ${SHA:0:7}"
git push origin "$BRANCH"
curl -sf -X POST \
-H "Authorization: token $FORGE_TOKEN" \
-H "Content-Type: application/json" \
"https://forge.quinefoundation.com/api/v1/repos/Cold-Air-Networks/petersweb-infra/pulls" \
-d "{
\"title\": \"bump riverside to ${SHA:0:7}\",
\"head\": \"$BRANCH\",
\"base\": \"main\",
\"body\": \"Automated bump from Cold-Air-Networks/customer-riverside@$SHA\"
}"

117
CLAUDE.md
View file

@ -1,4 +1,4 @@
# Riverside Therapeutics — Project Context # Riverside Physical Therapy — Project Context
## What this is ## What this is
@ -7,22 +7,65 @@ A Drupal 11 site for Riverside Physical Therapy. Nearly all frontend work lives
## Running locally ## Running locally
```bash ```bash
docker compose up # starts app on http://localhost:8080 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 docker compose exec app drush cr # clear Drupal cache
npm run watch # Tailwind CSS watcher (run on host, not in container) npm run watch # Tailwind CSS watcher (run on host, not in container)
npm run build # minified production build 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):**
```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:
```bash
docker compose exec app drush riverside:rebuild
# or the short alias
docker compose exec app drush rrb
```
**Note on semaphore/lock errors in logs:** During rebuilds (especially the full non-`DRUPAL_FAST` path) you may see Postgres errors like:
```
ERROR: duplicate key value violates unique constraint "semaphore____pkey"
DETAIL: Key (name)=(state:Drupal\Core\Cache\CacheCollector) already exists.
```
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:
```bash
docker compose exec app drush sql:query "TRUNCATE TABLE semaphore;"
```
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. 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 ## Stack
- **Drupal 11** (core_version_requirement: ^11) - **Drupal 11** (core_version_requirement: ^11)
- **PHP 8.5-fpm** (in Docker) - **PHP 8.5-fpm** (in Docker)
- **PostgreSQL 18** (db: drupal, user: drupal, pass: drupal) - **PostgreSQL 18** (db: drupal, user: drupal, pass: drupal)
- **Tailwind CSS v3** — compiled on the host via npm, output to `app.css` - **Tailwind CSS v3** — compiled on the host via npm, output to `app.css`
- **FullCalendar 6** — downloaded at build time, used on the schedule page - **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`)
- **Postmark** — email via `drupal/symfony_mailer` - **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` ## Custom module: `riverside_pt`
@ -33,7 +76,7 @@ All site-specific code lives here: `web/modules/custom/riverside_pt/`
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `templates/riverside-pt-header.html.twig` | Fixed top nav with hamburger on mobile | | `templates/riverside-pt-header.html.twig` | Fixed top nav with hamburger on mobile |
| `templates/riverside-pt-home.html.twig` | Home page: hero section + services grid | | `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. The header is injected globally via `riverside_pt_page_top()` in `.module`, not rendered by a controller.
@ -47,26 +90,70 @@ The header is injected globally via `riverside_pt_page_top()` in `.module`, not
| `riverside_pt.booking_store_slot` | `/schedule/book/slot` (POST) | `ScheduleController::storeSlot` | | `riverside_pt.booking_store_slot` | `/schedule/book/slot` (POST) | `ScheduleController::storeSlot` |
| `riverside_pt.schedule_events` | `/schedule/events` | `ScheduleController::events` | | `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 MonFri only (skips Sat/Sun via `N > 5` day-of-week check).
### CSS / JS ### CSS / JS
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `css/tailwind.css` | Tailwind entry point (`@tailwind base/components/utilities` + custom layers) | | `css/tailwind.css` | Tailwind entry point (`@tailwind base/components/utilities` + custom layers) |
| `css/app.css` | Compiled Tailwind output — **do not edit directly** | | `css/app.css` | Compiled Tailwind output — **do not edit directly** |
| `css/calendar.css` | FullCalendar overrides | | `css/calendar.css` | FullCalendar overrides + booking layout + slot button styles |
| `js/nav.js` | Hamburger toggle — adds/removes `is-open` on `#rpt-main-nav` | | `js/nav.js` | Hamburger toggle — adds/removes `is-open` on `#rpt-main-nav` |
| `js/calendar.js` | FullCalendar init and slot-selection logic | | `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:
```js
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 ### Libraries
Defined in `riverside_pt.libraries.yml`: Defined in `riverside_pt.libraries.yml`:
- `riverside_pt/app``app.css` + `nav.js`, attached globally via `riverside_pt_page_attachments()` - `riverside_pt/app``app.css` + `nav.js` + all `js/components/*.js` (as `type: module`), attached globally via `riverside_pt_page_attachments()`
- `riverside_pt/schedule``calendar.css` + `fullcalendar.min.js` + `calendar.js` - `riverside_pt/schedule``calendar.css` + `fullcalendar.min.js` + `calendar.js` + `core/drupalSettings`
## Home page sections (in order)
1. **Hero** — two-layer flex layout (image absolute, text relative); 2xl breakpoint adds a side-by-side split with solid teal panel
2. **Services grid** — 4-column card grid (`xl:grid-cols-4`)
3. **Mission / stats**`bg-[#dde8f0]`, `max-w-[1200px]` content container with neck.jpg image
4. **Testimonials**`<rpt-testimonials>` carousel; cards overflow right of a `max-w-[1200px]` safe area; pixel-offset `position:relative; left` animation; pointer drag + swipe
5. **Book An Appointment**`<rpt-appt-type>` selector above the FullCalendar booking widget; slots display inline beside the calendar
6. **FAQ**`<rpt-faq>` accordion; `grid-template-rows: 0fr → 1fr` expand animation
## Booking calendar details
- **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
- **`--cal-row-h`**: CSS custom property on `#riverside-calendar` controls row height; frame uses `calc(var(--cal-row-h) - 0.5rem)`
## Tailwind notes ## Tailwind notes
- Config scans `templates/**/*.twig` and `src/**/*.php` for class names - Config scans `templates/**/*.twig` and `src/**/*.php` for class names
- Breakpoints are standard Tailwind v3: `sm` = 640px, `md` = 768px - Breakpoints are standard Tailwind v3: `sm` = 640px, `md` = 768px, `2xl` = 1536px
- Mobile nav collapse (`max-height` slide) is in `tailwind.css` under `@layer components` because it can't be expressed with utilities - Mobile nav collapse (`max-height` slide) is in `tailwind.css` under `@layer components` because it can't be expressed with utilities
- Arbitrary Tailwind values use `_` for spaces: `bg-[#4a7a8a]`, `shadow-[-56px_2px_10px_#0000001A]` - 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:...]` - Non-standard CSS properties (e.g. `text-shadow`) use arbitrary property syntax: `[text-shadow:...]`
@ -76,9 +163,9 @@ Defined in `riverside_pt.libraries.yml`:
The hero uses two overlapping flex rows inside a `relative` section: 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 1** (`absolute inset-0 flex`): image layer, out of flow, fills the section
- **Box 2** (`relative flex min-h-[560px]`): text layer, in flow, sets section height - **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. 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 ## Development services
@ -90,4 +177,10 @@ Nav items come from Drupal's `main` menu. Items titled `"Book An Appointment"` o
## Email ## Email
Booking confirmation emails are sent via `riverside_pt_mail()` in `.module` using the `booking_request` key. Transport is Postmark, configured in `config/sync/symfony_mailer.mailer_transport.postmark.yml`. The API key is injected via the `POSTMARK_API_KEY` environment variable. 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`:
- 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).

View file

@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
locales \ locales \
curl \ curl \
gettext-base \ gettext-base \
procps \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \ && apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@ -80,9 +81,16 @@ COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY docker/php/entrypoint.sh /entrypoint.sh COPY docker/php/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN chown -R www-data:www-data web/sites/default/files && \ # Pass container env vars through to PHP-FPM workers; log errors to /var/log.
chmod -R 755 web/sites/default/files && \ RUN sed -i 's|;error_log = log/php-fpm.log|error_log = /var/log/php-fpm.log|' /usr/local/etc/php-fpm.conf && \
chmod 444 web/sites/default/settings.php { \
echo 'clear_env = no'; \
echo 'catch_workers_output = yes'; \
echo 'php_admin_flag[log_errors] = on'; \
echo 'php_admin_value[error_log] = /var/log/php-fpm.www.log'; \
} >> /usr/local/etc/php-fpm.d/zz-env.conf
RUN chmod 444 web/sites/default/settings.php
EXPOSE 80 EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]

View file

@ -6,3 +6,50 @@ drush:
%: %:
@: @:
# =============================================================================
# Docker build & publish targets
# Target registry: forge.quinefoundation.com/ironmagma/riverside
# =============================================================================
REGISTRY ?= forge.quinefoundation.com/ironmagma
IMAGE ?= riverside
TAG ?= latest
PLATFORM ?= linux/amd64,linux/arm64
IMAGE_NAME := $(REGISTRY)/$(IMAGE):$(TAG)
.PHONY: docker-build docker-push docker-push-latest help
# Build the multi-arch image locally (does NOT push)
docker-build:
docker buildx build --platform $(PLATFORM) -t $(IMAGE_NAME) .
# Build (if needed) + push to the registry
docker-push:
docker buildx build --platform $(PLATFORM) -t $(IMAGE_NAME) --push .
# Convenience: push the :latest tag to Forge (will build if necessary)
docker-push-latest:
$(MAKE) docker-push TAG=latest
# Two-step workflow example:
# 1. make docker-build # build locally first
# 2. make docker-push-latest # then push to forge.../riverside:latest
help:
@echo "Docker image targets (pushes to $(REGISTRY)/$(IMAGE))"
@echo ""
@echo " make docker-build Build multi-arch image locally (no push)"
@echo " make docker-push Build + push $(IMAGE_NAME)"
@echo " make docker-push-latest Push to $(REGISTRY)/$(IMAGE):latest"
@echo ""
@echo "Two-step workflow (build first, then push to latest):"
@echo " make docker-build"
@echo " make docker-push-latest"
@echo ""
@echo "Variables (can be overridden):"
@echo " REGISTRY=$(REGISTRY)"
@echo " IMAGE=$(IMAGE)"
@echo " TAG=$(TAG)"
@echo " PLATFORM=$(PLATFORM)"

View file

@ -1,4 +1,4 @@
# Riverside Patient Tracker # Riverside Physical Therapy
A Drupal-based appointment scheduling site for booking sessions between patients and practitioners. A Drupal-based appointment scheduling site for booking sessions between patients and practitioners.
@ -8,6 +8,8 @@ A Drupal-based appointment scheduling site for booking sessions between patients
docker compose up --build docker compose up --build
``` ```
**Default behavior**: Every start performs a full database wipe + rebuilds the entire site from code (content types, fields, menu, etc.). See CLAUDE.md for details and the `DRUPAL_FAST=1` escape hatch for faster iteration.
Admin login: `admin` / `admin` at `/user/login` Admin login: `admin` / `admin` at `/user/login`
## Makefile commands ## Makefile commands

7
build.sh Normal file → Executable file
View file

@ -1 +1,6 @@
docker buildx build --platform linux/amd64,linux/arm64 -t forge.quinefoundation.com/ironmagma/riverside:latest --push . #!/usr/bin/env bash
# Convenience wrapper for the Makefile
set -euo pipefail
make docker-build-push "$@"

View file

@ -10,12 +10,16 @@ services:
DB_NAME: drupal DB_NAME: drupal
DB_USER: drupal DB_USER: drupal
DB_PASS: drupal DB_PASS: drupal
SITE_NAME: "Riverside Therapeutics" SITE_NAME: "Riverside Physical Therapy"
ADMIN_PASS: "${ADMIN_PASS:?ADMIN_PASS is required}" ADMIN_PASS: "${ADMIN_PASS:?ADMIN_PASS is required}"
HASH_SALT: "${HASH_SALT:?HASH_SALT is required}" HASH_SALT: "${HASH_SALT:?HASH_SALT is required}"
DEBUG: "${DEBUG:-true}" DEBUG: "${DEBUG:-true}"
POSTMARK_API_KEY: "${POSTMARK_API_KEY:?POSTMARK_API_KEY is required}" POSTMARK_API_KEY: "${POSTMARK_API_KEY:?POSTMARK_API_KEY is required}"
BASE_URL: "${BASE_URL:-http://localhost:8080}" BASE_URL: "${BASE_URL:-http://localhost:8080}"
# DRUPAL_FAST=1 skips the automatic full database wipe + reinstall on every start.
# Use this for faster iteration when you don't want a completely fresh site.
# Default (no flag) = always wipe DB and rebuild everything from code.
DRUPAL_FAST: "${DRUPAL_FAST:-}"
volumes: volumes:
- ./web/sites/default/files:/var/www/html/web/sites/default/files - ./web/sites/default/files:/var/www/html/web/sites/default/files
- ./web/sites/default/settings.php:/var/www/html/web/sites/default/settings.php - ./web/sites/default/settings.php:/var/www/html/web/sites/default/settings.php

View file

@ -12,6 +12,9 @@ for var in SITE_NAME ADMIN_PASS; do
fi fi
done done
chown -R www-data:www-data /var/www/html/web/sites/default/files
chmod -R 755 /var/www/html/web/sites/default/files
echo "[entrypoint] Waiting for PostgreSQL at ${DB_HOST}..." echo "[entrypoint] Waiting for PostgreSQL at ${DB_HOST}..."
until pg_isready -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q; do until pg_isready -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -q; do
sleep 1 sleep 1
@ -22,20 +25,32 @@ cd /var/www/html
DRUSH="vendor/bin/drush --root=/var/www/html/web" DRUSH="vendor/bin/drush --root=/var/www/html/web"
HAS_TABLES=$($DRUSH sql:query \ echo "[entrypoint] Preparing database..."
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" \
2>/dev/null || echo "0")
if [ "$HAS_TABLES" != "1" ]; then if [ "${DRUPAL_FAST:-}" = "1" ]; then
echo "[entrypoint] Fresh database, installing Drupal..." echo "[entrypoint] DRUPAL_FAST=1 — skipping database wipe and full site reinstall."
else
echo "[entrypoint] Full rebuild mode (default). Dropping database..."
$DRUSH sql:drop -y || true
echo "[entrypoint] Installing Drupal (standard profile)..."
$DRUSH site:install standard \ $DRUSH site:install standard \
--site-name="$SITE_NAME" \ --site-name="$SITE_NAME" \
--account-name=admin \ --account-name=admin \
--account-pass="$ADMIN_PASS" \ --account-pass="$ADMIN_PASS" \
-y || { echo "[entrypoint] FATAL: site:install failed."; exit 1; } -y || { echo "[entrypoint] FATAL: site:install failed."; exit 1; }
echo "[entrypoint] Drupal installed." echo "[entrypoint] Drupal installed."
# Clear semaphores immediately after fresh install (prevents early
# duplicate key errors during first module enables + rebuild).
$DRUSH sql:query "TRUNCATE TABLE semaphore;" 2>/dev/null || true
fi fi
# Always clear stale semaphores before module enables + rebuild.
# This is the most reliable way to avoid the duplicate key errors
# on "semaphore" (CacheCollector, cron, state locks, etc.).
$DRUSH sql:query "TRUNCATE TABLE semaphore;" 2>/dev/null || true
echo "[entrypoint] Enabling required modules..." echo "[entrypoint] Enabling required modules..."
$DRUSH en -y views views_ui field_ui text options link datetime && \ $DRUSH en -y views views_ui field_ui text options link datetime && \
echo "[entrypoint] Core modules enabled." || echo "[entrypoint] WARNING: core modules failed." echo "[entrypoint] Core modules enabled." || echo "[entrypoint] WARNING: core modules failed."
@ -43,26 +58,64 @@ $DRUSH en -y webform webform_ui && \
echo "[entrypoint] Webform enabled." || echo "[entrypoint] WARNING: webform failed." echo "[entrypoint] Webform enabled." || echo "[entrypoint] WARNING: webform failed."
$DRUSH en -y symfony_mailer && \ $DRUSH en -y symfony_mailer && \
echo "[entrypoint] Mailer enabled." || echo "[entrypoint] WARNING: symfony_mailer failed." echo "[entrypoint] Mailer enabled." || echo "[entrypoint] WARNING: symfony_mailer failed."
# Mail transport is configured via $config['system.mail']['mailer_dsn'] in settings.php,
# which is read directly by Drupal core's symfony_mailer mail plugin.
# Do NOT use the mailer_transport module's entity system for this — it is only a UI layer
# and is not consulted by core when actually sending mail.
$DRUSH en -y riverside_pt && \ $DRUSH en -y riverside_pt && \
echo "[entrypoint] riverside_pt enabled." || echo "[entrypoint] WARNING: riverside_pt failed." echo "[entrypoint] riverside_pt enabled." || echo "[entrypoint] WARNING: riverside_pt failed."
# Clear semaphores to avoid duplicate key violations on the semaphore
# table (e.g. during CacheCollector, cron, state operations) that can
# occur during rapid config/entity changes in the rebuild.
$DRUSH sql:query "TRUNCATE TABLE semaphore;" 2>/dev/null || true
echo "[entrypoint] Rebuilding site structure from code (riverside:rebuild)..."
$DRUSH riverside:rebuild || echo "[entrypoint] WARNING: riverside:rebuild encountered an issue."
# Re-assert a few key pieces (cheap and safe).
$DRUSH theme:enable starterkit_theme claro_compact -y && \ $DRUSH theme:enable starterkit_theme claro_compact -y && \
$DRUSH config:set system.theme default starterkit_theme -y && \ $DRUSH config:set system.theme default starterkit_theme -y && \
$DRUSH config:set system.theme admin claro_compact -y && \ $DRUSH config:set system.theme admin claro_compact -y && \
echo "[entrypoint] Themes set." || echo "[entrypoint] WARNING: theme enable failed." echo "[entrypoint] Themes set." || echo "[entrypoint] WARNING: theme enable failed."
$DRUSH config:set system.site page.front /home -y && \ $DRUSH config:set system.site page.front /home -y && \
echo "[entrypoint] Front page set." || echo "[entrypoint] WARNING: front page set failed." echo "[entrypoint] Front page set." || echo "[entrypoint] WARNING: front page set failed."
if ls /var/www/html/config/sync/*.yml >/dev/null 2>&1; then
echo "[entrypoint] Importing configuration..."
$DRUSH config:import -y && \
echo "[entrypoint] Config imported." || echo "[entrypoint] WARNING: config import failed."
fi
npm run build --prefix /var/www/html >/dev/null 2>&1 && echo "[entrypoint] Tailwind built." || echo "[entrypoint] WARNING: Tailwind build failed." npm run build --prefix /var/www/html >/dev/null 2>&1 && echo "[entrypoint] Tailwind built." || echo "[entrypoint] WARNING: Tailwind build failed."
# One more semaphore clear before the final cache rebuild (common source
# of the duplicate key errors seen in logs).
$DRUSH sql:query "TRUNCATE TABLE semaphore;" 2>/dev/null || true
$DRUSH cache:rebuild >/dev/null 2>&1 && echo "[entrypoint] Cache rebuilt." $DRUSH cache:rebuild >/dev/null 2>&1 && echo "[entrypoint] Cache rebuilt."
if [ "${DEBUG:-false}" = "true" ]; then
# Mock sendmail on localhost/dev: prints full email to stderr (visible in docker logs)
# instead of erroring with "sh: 1: /usr/sbin/sendmail: not found".
# This catches any php_mail / legacy mail() calls (e.g. some webforms, fallbacks).
# Real emails should still go via symfony_mailer + Postmark when configured.
cat > /usr/local/bin/fake-sendmail.sh << 'FAKE_SENDMAIL'
#!/bin/sh
echo "=== MOCK SENDMAIL (dev - email logged, not sent) ===" >&2
echo "Timestamp: $(date -Iseconds)" >&2
echo "Called as: $0 $*" >&2
echo "----- EMAIL CONTENT -----" >&2
cat >&2
echo "" >&2
echo "=== END MOCK SENDMAIL ===" >&2
exit 0
FAKE_SENDMAIL
chmod +x /usr/local/bin/fake-sendmail.sh
echo "[entrypoint] Installed fake sendmail for dev logging."
# Override sendmail_path for PHP (affects php_mail interface and any direct mail()).
echo 'sendmail_path = /usr/local/bin/fake-sendmail.sh' > /usr/local/etc/php/conf.d/sendmail.ini 2>/dev/null || true
else
# Ensure no dev mock leaks into prod: remove any sendmail_path override and the fake script.
rm -f /usr/local/etc/php/conf.d/sendmail.ini /usr/local/bin/fake-sendmail.sh 2>/dev/null || true
fi
if [ "${DEBUG:-false}" = "true" ]; then if [ "${DEBUG:-false}" = "true" ]; then
NGINX_CSS_CACHE='expires off; add_header Cache-Control "no-store";' NGINX_CSS_CACHE='expires off; add_header Cache-Control "no-store";'
else else

9
docker/php/fake-sendmail.sh Executable file
View file

@ -0,0 +1,9 @@
#!/bin/sh
echo "=== MOCK SENDMAIL (dev - email logged, not sent) ===" >&2
echo "Timestamp: $(date -Iseconds)" >&2
echo "Called as: $0 $*" >&2
echo "----- EMAIL CONTENT -----" >&2
cat >&2
echo "" >&2
echo "=== END MOCK SENDMAIL ===" >&2
exit 0

View file

@ -7,8 +7,31 @@ module.exports = {
], ],
theme: { theme: {
extend: { extend: {
colors: {
'pt-blue': {
50: '#e8f2f6',
100: '#dde8f0',
200: '#b8d4dc',
300: '#9dbdcb',
400: '#86aab6',
500: '#306f8e',
600: '#1f5a6e',
},
'pt-sage': {
400: '#83a1a1',
500: '#6f8f96',
},
'pt-navy': '#1e3a5f',
},
screens: { screens: {
'md': '920px', // Adjusted for current hybrid usage (hero changes at sm, header/layout at md)
'sm': '768px', // iPad portrait + when hero layout activates
'md': '1024px', // Desktop start (header fixed, mission row, etc.)
'lg': '1280px', // Large desktop
'2xl': '1780px', // Ultra-wide
},
fontFamily: {
hedvig: ['Hedvig Letters Sans', 'sans-serif'],
}, },
}, },
}, },

File diff suppressed because one or more lines are too long

View file

@ -1,88 +1,204 @@
#riverside-calendar { .riverside-booking-wrap {
max-width: 480px;
font-size: 0.8rem;
}
#riverside-booking-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 999;
}
#riverside-booking-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90%;
max-width: 340px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 1rem;
z-index: 1000;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.riverside-booking-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 2rem;
margin-bottom: 0.75rem; align-items: flex-start;
font-weight: 600;
} }
#riverside-booking-close { @media (min-width: 640px) {
background: none; .riverside-booking-wrap {
border: none; flex-direction: row;
cursor: pointer; gap: 2rem;
font-size: 1rem; }
line-height: 1; }
padding: 0;
color: #6b7280; #riverside-calendar {
--cal-row-h: 3.75rem;
max-width: 340px;
font-size: 0.9rem;
flex-shrink: 0;
}
#riverside-slots-wrap {
flex: 1;
padding-top: 3rem;
} }
#riverside-booking-slots { #riverside-booking-slots {
list-style: none; display: grid;
margin: 0; grid-template-columns: repeat(3, 1fr);
padding: 0; gap: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
} }
#riverside-booking-slots li { .riverside-slot-btn {
padding: 0.5rem 0.75rem; padding: 0.6rem 0.5rem;
border: 1px solid #3b82f6; border: 1.5px solid #306f8e;
border-radius: 4px; border-radius: 6px;
font-size: 0.8rem;
color: #306f8e;
background: #fff;
cursor: pointer; cursor: pointer;
font-size: 0.85rem;
color: #1d4ed8;
}
#riverside-booking-slots li:hover {
background: #eff6ff;
}
#riverside-calendar .fc-more-popover {
display: none;
}
#riverside-calendar .is-holiday .fc-more-link {
display: none;
}
#riverside-calendar .fc-day-other .riverside-holiday-label,
#riverside-calendar .fc-day-other .fc-more-link {
opacity: 0.4;
}
#riverside-calendar .riverside-holiday-label {
overflow-wrap: break-word;
line-height: 1;
font-size: 0.65rem;
color: #b45309;
text-align: center; text-align: center;
padding: 2px; transition: background 0.15s, color 0.15s;
} }
.riverside-slot-btn:hover:not(.is-selected) {
background: #dde8f0;
}
.riverside-slot-btn.is-selected {
background: #306f8e;
color: #fff;
}
/* ── Strip all borders ── */
#riverside-calendar .fc-scrollgrid,
#riverside-calendar .fc-scrollgrid-section > *,
#riverside-calendar td,
#riverside-calendar th {
border: none !important;
}
/* ── Header toolbar ── */
#riverside-calendar .fc-header-toolbar {
justify-content: center;
gap: 0.25rem;
margin-bottom: 1.25rem;
}
#riverside-calendar .fc-toolbar-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.5rem;
font-weight: normal;
color: #111;
}
#riverside-calendar .fc-button-group,
#riverside-calendar .fc-button {
background: none !important;
border: none !important;
box-shadow: none !important;
color: #6b7280 !important;
font-size: 1rem;
padding: 0.2rem 0.4rem;
cursor: pointer;
}
#riverside-calendar .fc-button:hover {
color: #111 !important;
}
#riverside-calendar .fc-button:focus {
outline: none !important;
box-shadow: none !important;
}
/* ── Day-of-week header row (S M T W T F S) ── */
#riverside-calendar .fc-col-header-cell {
padding: 0.4rem 0;
border: none;
}
#riverside-calendar .fc-col-header-cell-cushion {
font-size: 0.8rem;
font-weight: normal;
color: #9ca3af;
text-decoration: none;
padding: 0;
}
/* ── Day cells ── */
#riverside-calendar .fc-daygrid-day-frame {
min-height: calc(var(--cal-row-h) - 0.5rem) !important;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 0;
}
#riverside-calendar .fc-daygrid-day-events,
#riverside-calendar .fc-daygrid-day-bg {
display: none !important;
}
#riverside-calendar .fc-daygrid-day-top {
flex-direction: row;
justify-content: center;
padding: 0;
}
#riverside-calendar .fc-daygrid-day-number {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
color: #9ca3af;
text-decoration: none;
padding: 0;
line-height: 1;
transition: background 0.15s, color 0.15s;
}
/* Days with available slots — teal outline circle */
#riverside-calendar .fc-daygrid-day.has-availability .fc-daygrid-day-number {
border: 1px solid #306f8e;
color: #111;
cursor: pointer;
}
#riverside-calendar .fc-daygrid-day.has-availability .fc-daygrid-day-number:hover {
background: #dde8f0;
}
/* Selected day — filled teal circle */
#riverside-calendar .fc-daygrid-day.is-selected .fc-daygrid-day-number {
background: #306f8e;
border: 1px solid #306f8e;
color: #fff !important;
}
/* Days in other months */
#riverside-calendar .fc-day-other .fc-daygrid-day-number {
color: #d1d5db !important;
border-color: transparent !important;
}
/* Today — no special background, let availability/selected styles win */
#riverside-calendar .fc-day-today {
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 {
background: none !important;
}
#riverside-calendar .fc-col-header-cell.fc-day-sat .fc-col-header-cell-cushion,
#riverside-calendar .fc-col-header-cell.fc-day-sun .fc-col-header-cell-cushion {
color: #9ca3af;
}
/* ── Hide all event bars, harnesses, and more-links ── */
#riverside-calendar .fc-event,
#riverside-calendar .fc-daygrid-event-harness,
#riverside-calendar .fc-daygrid-more-link,
#riverside-calendar .fc-more-popover,
#riverside-calendar .riverside-holiday-label {
display: none !important;
}
/* ── Daygrid body rows — give them breathing room ── */
#riverside-calendar .fc-daygrid-body tr {
height: var(--cal-row-h);
}

View file

@ -1,8 +1,14 @@
@import url('https://fonts.googleapis.com/css2?family=Hedvig+Letters+Sans:wght@400;500;600&display=swap');
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
html {
background-color: theme('colors.pt-blue.400');
scroll-behavior: smooth;
}
/* Neutralise any theme container constraints */ /* Neutralise any theme container constraints */
.page-wrapper { .page-wrapper {
max-width: none; max-width: none;
@ -17,6 +23,46 @@
} }
@layer components { @layer components {
/* Shared design tokens */
.rpt-link {
@apply text-pt-blue-500 hover:underline;
}
.rpt-btn {
@apply inline-flex items-center gap-3 px-5 py-3 bg-pt-blue-500 text-white text-[15px] font-medium no-underline transition-colors hover:bg-pt-blue-600;
}
.rpt-section {
@apply py-16 px-6 bg-white;
}
.rpt-container {
@apply max-w-[1040px] mx-auto;
}
.rpt-heading-lg {
@apply text-2xl font-normal text-gray-900;
}
.rpt-heading-md {
@apply text-xl font-normal mb-3;
}
.rpt-body-text {
@apply text-[15px] text-gray-600 leading-relaxed;
}
.rpt-eyebrow {
@apply text-xs tracking-widest uppercase text-pt-blue-500 font-semibold;
}
/* Service/info card */
.rpt-card {
@apply flex flex-col border border-pt-blue-200 bg-white overflow-hidden;
}
.rpt-card__body {
@apply flex flex-col gap-4 p-6 flex-1;
}
.rpt-card__img {
@apply w-full h-48 object-cover;
}
/* Rounded info card (about page pillars, etc.) */
.rpt-card-rounded {
@apply bg-white p-6 rounded-xl border border-pt-blue-200;
}
/* Mobile nav: max-height slide can't be expressed with utilities alone */ /* Mobile nav: max-height slide can't be expressed with utilities alone */
@media (max-width: calc(theme('screens.md') - 1px)) { @media (max-width: calc(theme('screens.md') - 1px)) {
#rpt-main-nav { #rpt-main-nav {
@ -25,7 +71,7 @@
order: 10; order: 10;
width: 100%; width: 100%;
background: #fff; background: #fff;
border-top: 1px solid #e5e7eb; border-top: 1px solid theme('colors.gray.200');
max-height: 0; max-height: 0;
overflow: hidden; overflow: hidden;
opacity: 0; opacity: 0;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View file

@ -1,11 +1,93 @@
(function (drupalSettings) { (function (drupalSettings) {
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const el = document.getElementById('riverside-calendar'); var el = document.getElementById('riverside-calendar');
if (!el) return; if (!el) return;
const calendar = new FullCalendar.Calendar(el, { requestAnimationFrame(function () {
var slotsWrap = document.getElementById('riverside-slots-wrap');
var slotsGrid = document.getElementById('riverside-booking-slots');
var selectedDate = null;
var initialized = false;
var currentService = 'diagnostic';
function buildEventsUrl(service) {
return drupalSettings.riversidePt.eventsUrl + '?service=' + service;
}
function localDateStr(d) {
return d.getFullYear() + "-" +
String(d.getMonth() + 1).padStart(2, "0") + "-" +
String(d.getDate()).padStart(2, "0");
}
function nextBusinessDay() {
var d = new Date();
d.setDate(d.getDate() + 1);
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
return localDateStr(d);
}
var initDate = nextBusinessDay();
function slotLabel(date) {
var h = date.getHours();
return (h % 12 || 12) + (h < 12 ? 'AM' : 'PM') + ' PST';
}
function renderSlots(dateStr, events) {
var dayEvents = events
.filter(function (e) { return e.startStr.startsWith(dateStr); })
.sort(function (a, b) { return a.start - b.start; });
if (!slotsWrap || !slotsGrid || dayEvents.length === 0) return;
slotsGrid.innerHTML = '';
dayEvents.forEach(function (event, idx) {
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = slotLabel(event.start);
btn.className = 'riverside-slot-btn' + (idx === 0 ? ' is-selected' : '');
btn.addEventListener('click', function () {
slotsGrid.querySelectorAll('.riverside-slot-btn').forEach(function (b) {
b.classList.remove('is-selected');
});
btn.classList.add('is-selected');
fetch(drupalSettings.riversidePt.storeSlotUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start: event.startStr, end: event.endStr, service: currentService }),
}).then(function (res) {
if (res.ok) {
window.location.href = drupalSettings.riversidePt.bookingUrl;
} else {
btn.textContent += ' (unavailable)';
btn.disabled = true;
}
});
});
slotsGrid.appendChild(btn);
});
slotsWrap.hidden = false;
}
function selectDay(dateStr, events) {
el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
d.classList.remove('is-selected');
});
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
if (dayEl) dayEl.classList.add('is-selected');
selectedDate = dateStr;
renderSlots(dateStr, events);
}
var calendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth', initialView: 'dayGridMonth',
initialDate: initDate,
headerToolbar: { left: 'prev', center: 'title', right: 'next' }, headerToolbar: { left: 'prev', center: 'title', right: 'next' },
titleFormat: { year: 'numeric', month: 'long' },
dayHeaderFormat: { weekday: 'narrow' },
validRange: function (now) { validRange: function (now) {
return { return {
start: new Date(now.getFullYear(), now.getMonth(), 1), start: new Date(now.getFullYear(), now.getMonth(), 1),
@ -13,93 +95,51 @@
}; };
}, },
fixedWeekCount: false, fixedWeekCount: false,
showNonCurrentDates: false,
height: 'auto', height: 'auto',
events: drupalSettings.riversidePt.eventsUrl, events: buildEventsUrl(currentService),
eventBackgroundColor: '#3b82f6', eventDisplay: 'none',
eventBorderColor: '#3b82f6', dayMaxEvents: false,
dayMaxEvents: 0,
moreLinkContent: function (arg) { datesSet: function () {
return arg.num + ' slot' + (arg.num !== 1 ? 's' : ''); el.querySelectorAll('.fc-daygrid-day.is-selected').forEach(function (d) {
d.classList.remove('is-selected');
});
selectedDate = null;
if (slotsWrap) slotsWrap.hidden = true;
}, },
eventsSet: function (events) {
el.querySelectorAll('.fc-daygrid-day.has-availability').forEach(function (d) {
d.classList.remove('has-availability');
});
events.forEach(function (event) {
var dateStr = event.startStr.substring(0, 10);
var dayEl = el.querySelector('.fc-daygrid-day[data-date="' + dateStr + '"]');
if (dayEl) dayEl.classList.add('has-availability');
});
if (!initialized) {
initialized = true;
var targetEl = el.querySelector('.fc-daygrid-day[data-date="' + initDate + '"]');
if (targetEl && targetEl.classList.contains('has-availability')) {
selectDay(initDate, events);
}
}
},
dayCellClassNames: function (arg) { dayCellClassNames: function (arg) {
const date = arg.date.toISOString().substring(0, 10); var date = arg.date.toISOString().substring(0, 10);
if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday']; if (drupalSettings.riversidePt.holidays[date]) return ['is-holiday'];
}, },
dayCellDidMount: function (arg) {
const date = arg.date.toISOString().substring(0, 10); dateClick: function (arg) {
const holiday = drupalSettings.riversidePt.holidays[date]; if (!arg.dayEl.classList.contains('has-availability')) return;
if (!holiday) return; selectDay(arg.dateStr, calendar.getEvents());
const label = document.createElement('div');
label.className = 'riverside-holiday-label';
label.textContent = holiday;
const dayTop = arg.el.querySelector('.fc-daygrid-day-top');
if (dayTop) {
dayTop.insertAdjacentElement('afterend', label);
} else {
arg.el.appendChild(label);
}
}, },
moreLinkClick: function (arg) {
arg.jsEvent.preventDefault();
arg.jsEvent.stopPropagation();
const date = arg.date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
panelDate.textContent = date;
panelSlots.innerHTML = '';
arg.allSegs.forEach(function (seg) {
const li = document.createElement('li');
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 = '#';
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);
});
openPanel();
return false;
},
});
const panel = document.getElementById('riverside-booking-panel');
const backdrop = document.getElementById('riverside-booking-backdrop');
const panelDate = document.getElementById('riverside-booking-date');
const panelSlots = document.getElementById('riverside-booking-slots');
function closePanel() {
panel.hidden = true;
backdrop.hidden = true;
}
function openPanel() {
backdrop.hidden = false;
panel.hidden = false;
}
document.getElementById('riverside-booking-close').addEventListener('click', closePanel);
backdrop.addEventListener('click', closePanel);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closePanel();
}); });
calendar.render(); calendar.render();
}); });
});
})(drupalSettings); })(drupalSettings);

View file

@ -0,0 +1,71 @@
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";
const TYPES = [
{ id: "diagnostic", label: "Diagnostic Assessment", duration: "60 MINS" },
{ id: "sports", label: "Sports Rehabilitation", duration: "60 MINS" },
{ id: "surgical", label: "Surgery Rehabilitation", duration: "60 MINS" },
{ id: "neuro", label: "Neurological Therapy", duration: "60 MINS" },
];
const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<polyline points="1,5.5 5,9.5 13,1" stroke="white" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`;
function ApptType() {
const [selected, setSelected] = useState("diagnostic");
function select(id) {
setSelected(id);
document.dispatchEvent(new CustomEvent("rpt:appt-type-change", { detail: { type: id } }));
}
return html`
<div>
<p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-5">Select Appointment Type</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
${TYPES.map(function (t) {
var active = selected === t.id;
return html`
<button
key=${t.id}
onClick=${function () { select(t.id); }}
style="text-align:left; cursor:pointer;"
class=${
"flex items-center gap-4 p-4 w-full rounded-xl border transition-colors " +
(active ? "bg-pt-blue-500 border-pt-blue-500" : "bg-white border-pt-blue-200 hover:border-pt-blue-500")
}
>
<div class=${
"w-8 h-8 rounded-full shrink-0 flex items-center justify-center border " +
(active ? "border-white/60" : "border-pt-blue-200")
}>
${active ? CHECK : null}
</div>
<div>
<p class=${"font-serif text-[1.0625rem] font-normal leading-snug " + (active ? "text-white" : "text-gray-900")}>
${t.label}
</p>
<p class=${"text-[0.6875rem] tracking-widest font-semibold mt-0.5 " + (active ? "text-white/70" : "text-pt-blue-500")}>
${t.duration}
</p>
</div>
</button>
`;
})}
</div>
</div>
`;
}
class RptApptType extends HTMLElement {
connectedCallback() {
render(html`<${ApptType} />`, this);
}
disconnectedCallback() {
render(null, this);
}
}
customElements.define("rpt-appt-type", RptApptType);

View file

@ -0,0 +1,553 @@
import { h, render } from "https://esm.sh/preact@10";
import { useState, useEffect, useRef, useMemo } from "https://esm.sh/preact@10/hooks";
import { html } from "https://esm.sh/htm@3/preact";
const TYPES = [
{ id: "diagnostic", label: "Diagnostic Assessment", duration: "60 MINS" },
{ id: "sports", label: "Sports Rehabilitation", duration: "60 MINS" },
{ id: "surgical", label: "Surgery Rehabilitation", duration: "60 MINS" },
{ id: "neuro", label: "Neurological Therapy", duration: "60 MINS" },
];
const CHECK = html`<svg width="14" height="11" viewBox="0 0 14 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<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 = { firstName: "", lastName: "", email: "", phone: "", comments: "" };
function formatPhone(raw) {
let d = String(raw || "").replace(/\D/g, "");
if (d.length === 11 && d[0] === "1") {
const rest = d.slice(1);
return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6);
}
d = d.slice(0, 10);
if (d.length === 0) return "";
if (d.length <= 3) return d;
if (d.length <= 6) return "(" + d.slice(0, 3) + ") " + d.slice(3);
return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6);
}
// All Tailwind class strings live here so the JIT scanner sees complete
// literals in one place rather than spread across template expressions.
const CX = {
// ── Type selector ──────────────────────────────────────────────────────
selectorLabel: "text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-5",
selectorGrid: "grid grid-cols-1 sm:grid-cols-2 gap-3 mb-10",
typeBtn: "flex items-center gap-4 p-4 w-full rounded-xl border transition-colors",
typeBtnActive: "bg-pt-blue-500 border-pt-blue-500",
typeBtnInactive: "bg-white border-pt-blue-200 hover:border-pt-blue-500",
typeCircle: "w-8 h-8 rounded-full shrink-0 flex items-center justify-center border",
typeCircleActive: "border-white/60",
typeCircleInactive: "border-pt-blue-200",
typeLabel: "font-serif text-[1.0625rem] font-normal leading-snug",
typeLabelActive: "text-white",
typeLabelInactive: "text-gray-900",
typeDuration: "text-[0.6875rem] tracking-widest font-semibold mt-0.5",
typeDurationActive: "text-white/70",
typeDurationInactive: "text-pt-blue-500",
// ── Form ───────────────────────────────────────────────────────────────
formSection: "mt-8 pt-8 border-t border-pt-blue-200",
formHeading: "text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-6",
formGrid: "grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-5 mb-5",
formLabel: "block text-sm font-medium text-gray-700 mb-1",
formRequired: "text-red-500",
formInput: "w-full border border-pt-blue-200 bg-white px-3 py-2 text-gray-900 text-sm focus:outline-none focus:border-pt-blue-500 transition-colors",
formTextarea: "resize-none w-full border border-pt-blue-200 bg-white px-3 py-2 text-gray-900 text-sm focus:outline-none focus:border-pt-blue-500 transition-colors",
formError: "text-red-500 text-sm mb-4",
submitBtn: "px-[4em] py-[1em] bg-pt-blue-500 text-white text-sm font-medium transition-colors border-2 border-pt-blue-500 hover:bg-pt-blue-600 hover:border-pt-blue-600 disabled:opacity-50",
// ── Calendar overlay ──────────────────────────────────────────────────
calWrapper: "relative",
noSlotsOverlay: "absolute inset-0 z-10 flex items-center justify-center pt-20 pointer-events-none",
// ── Success state ──────────────────────────────────────────────────────
successSection: "mt-8 pt-8 border-t border-pt-blue-200",
successBox: "p-8 bg-green-50 border border-green-200 text-green-800",
successTitle: "text-2xl font-semibold mb-4",
successSummary: "flex flex-col gap-1 text-base mb-4",
successNote: "text-sm text-green-700",
};
function localDateStr(d) {
return d.getFullYear() + "-" +
String(d.getMonth() + 1).padStart(2, "0") + "-" +
String(d.getDate()).padStart(2, "0");
}
function nextBusinessDay() {
var d = new Date();
d.setDate(d.getDate() + 1);
while (d.getDay() === 0 || d.getDay() === 6) d.setDate(d.getDate() + 1);
return localDateStr(d);
}
function slotLabel(startStr) {
var d = new Date(startStr);
var h = d.getHours();
return (h % 12 || 12) + (h < 12 ? "AM" : "PM") + " PST";
}
function formatAppointmentDate(startStr) {
var parts = startStr.split("T")[0].split("-");
var d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
var date = d.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
return date + " at " + slotLabel(startStr);
}
// ── BookingPanel ──────────────────────────────────────────────────────────
// Keyed by service in the parent, so it always mounts fresh for each service.
// service is a prop here — it never changes within an instance's lifetime,
// which means the fetch effect can depend only on dateRange (no stale-service risk).
function BookingPanel({ service, settings }) {
const [dateRange, setDateRange] = useState(null);
const [fetchedEvents, setFetchedEvents] = useState(null);
const [fetchLoading, setFetchLoading] = useState(false);
const [slots, setSlots] = useState([]);
const [selectedSlotId, setSelectedSlotId] = useState(null);
const [formData, setFormData] = useState(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const [success, setSuccess] = useState(false);
const [confirmedAppointment, setConfirmedAppointment] = useState(null);
const [noSlotsInMonth, setNoSlotsInMonth] = useState(false);
const calEl = useRef(null);
const calRef = useRef(null);
const initializedRef = useRef(false);
const autoAdvanceRef = useRef(0);
const fetchAbortRef = useRef(null);
// Instance-scoped vars for FullCalendar callbacks (no stale-closure risk via .current).
const selectedDateRef = useRef(null);
const selectedDateSlotsRef = useRef([]);
const currentEventsRef = useRef([]);
const initDate = useMemo(nextBusinessDay, []);
const formRef = useRef(null);
const prevSlotIdRef = useRef(null);
const successRef = useRef(null);
function buildEventsUrl() {
return settings.eventsUrl + "?service=" + service;
}
// ── Initialize FullCalendar once ─────────────────────────────────────
useEffect(function () {
if (!calEl.current || !window.FullCalendar) return;
var cal = new FullCalendar.Calendar(calEl.current, {
initialView: "dayGridMonth",
initialDate: initDate,
headerToolbar: { left: "prev", center: "title", right: "next" },
titleFormat: { year: "numeric", month: "long" },
dayHeaderFormat: { weekday: "narrow" },
validRange: function (now) {
return {
start: new Date(now.getFullYear(), now.getMonth(), 1),
end: new Date(now.getFullYear(), now.getMonth() + 7, 1),
};
},
fixedWeekCount: false,
showNonCurrentDates: false,
height: "auto",
eventDisplay: "none",
dayMaxEvents: false,
datesSet: function (info) {
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected");
});
setSelectedSlotId(null);
setNoSlotsInMonth(false);
if (selectedDateRef.current) {
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + selectedDateRef.current + "\"]");
if (dayEl) {
dayEl.classList.add("is-selected");
setSlots(selectedDateSlotsRef.current);
} else {
setSlots([]);
}
} else {
setSlots([]);
}
setDateRange(info.startStr + "/" + info.endStr);
},
eventsSet: function (events) {
calEl.current.querySelectorAll(".fc-daygrid-day.has-availability").forEach(function (d) {
d.classList.remove("has-availability");
});
events.forEach(function (event) {
var dateStr = event.startStr.substring(0, 10);
var dayEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + dateStr + "\"]");
if (dayEl) dayEl.classList.add("has-availability");
});
},
dayCellClassNames: function (arg) {
var date = arg.date.toISOString().substring(0, 10);
if (settings.holidays[date]) return ["is-holiday"];
},
dateClick: function (arg) {
if (!arg.dayEl.classList.contains("has-availability")) return;
calEl.current.querySelectorAll(".fc-daygrid-day.is-selected").forEach(function (d) {
d.classList.remove("is-selected");
});
arg.dayEl.classList.add("is-selected");
var daySlots = currentEventsRef.current
.filter(function (e) { return e.start.startsWith(arg.dateStr); })
.sort(function (a, b) { return a.start < b.start ? -1 : 1; });
selectedDateRef.current = arg.dateStr;
selectedDateSlotsRef.current = daySlots;
setSelectedSlotId(null);
setSubmitError(null);
setSuccess(false);
setSlots(daySlots);
},
});
cal.render();
calRef.current = cal;
return function () { cal.destroy(); };
}, []);
// ── Fetch events when visible range changes ──────────────────────────
// service is a prop that never changes within this component instance
// (parent keys us by service), so only dateRange needs to be a dep.
useEffect(function () {
if (!dateRange) return;
if (fetchAbortRef.current) fetchAbortRef.current.abort();
var controller = new AbortController();
fetchAbortRef.current = controller;
var parts = dateRange.split("/");
var url = buildEventsUrl() + "&start=" + parts[0] + "&end=" + parts[1];
setFetchLoading(true);
setFetchedEvents(null);
fetch(url, { signal: controller.signal })
.then(function (r) { return r.json(); })
.then(function (data) {
currentEventsRef.current = data;
setFetchedEvents(data);
setFetchLoading(false);
})
.catch(function (err) {
if (err.name === "AbortError") return;
currentEventsRef.current = [];
setFetchedEvents([]);
setFetchLoading(false);
});
}, [dateRange]);
// ── Push fetched events into FullCalendar; auto-advance or auto-select ─
useEffect(function () {
if (fetchedEvents === null) return;
var cal = calRef.current;
if (!cal) return;
cal.removeAllEventSources();
if (fetchedEvents.length > 0) {
cal.addEventSource(fetchedEvents);
if (!initializedRef.current) {
var dates = [...new Set(fetchedEvents.map(function (e) { return e.start.substring(0, 10); }))].sort();
var firstDate = dates[0];
if (firstDate) {
var firstSlots = fetchedEvents
.filter(function (e) { return e.start.startsWith(firstDate); })
.sort(function (a, b) { return a.start < b.start ? -1 : 1; });
selectedDateRef.current = firstDate;
selectedDateSlotsRef.current = firstSlots;
var targetEl = calEl.current.querySelector(".fc-daygrid-day[data-date=\"" + firstDate + "\"]");
if (targetEl) {
initializedRef.current = true;
autoAdvanceRef.current = 0;
targetEl.classList.add("is-selected");
setSlots(firstSlots);
}
}
}
} else if (!initializedRef.current && autoAdvanceRef.current < 12) {
autoAdvanceRef.current++;
cal.next();
} else if (initializedRef.current) {
setNoSlotsInMonth(true);
}
}, [fetchedEvents]);
useEffect(function () {
if (selectedSlotId && !prevSlotIdRef.current && formRef.current) {
window.rptScrollTo(formRef.current, true);
}
prevSlotIdRef.current = selectedSlotId;
}, [selectedSlotId]);
useEffect(function () {
if (success && successRef.current) {
window.rptScrollTo(successRef.current, true);
}
}, [success]);
function handleSlotClick(slot) {
setSelectedSlotId(slot.id);
setSubmitError(null);
setSuccess(false);
}
function handleFormChange(field, value) {
setFormData(function (prev) { return Object.assign({}, prev, { [field]: value }); });
}
function handleSubmit(e) {
e.preventDefault();
var slot = slots.find(function (s) { return s.id === selectedSlotId; });
if (!slot) return;
setSubmitting(true);
setSubmitError(null);
fetch(settings.storeSlotUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
start: slot.start,
end: slot.end,
service: service,
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone,
comments: formData.comments,
}),
}).then(function (res) {
if (res.ok) {
setSubmitting(false);
setSubmitError(null);
setConfirmedAppointment({
start: slot.start,
service: service,
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
});
setSuccess(true);
setSelectedSlotId(null);
setFormData(EMPTY_FORM);
} else {
setSubmitting(false);
if (res.status === 422) {
setSubmitError("That slot was just booked. Please choose another time.");
} else {
res.json().then(function (data) {
setSubmitError(data.message || "Something went wrong. Please try again.");
}).catch(function () {
setSubmitError("Something went wrong. Please try again.");
});
}
}
}).catch(function () {
setSubmitting(false);
setSubmitError("Something went wrong. Please try again.");
});
}
var selectedSlot = slots.find(function (s) { return s.id === selectedSlotId; });
return html`
<div>
${!success ? html`
<div class="riverside-booking-wrap">
<div class=${CX.calWrapper}>
<div ref=${calEl} id="riverside-calendar"></div>
${noSlotsInMonth ? html`
<div class=${CX.noSlotsOverlay}>
<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">
<p class="text-xs text-gray-500 mb-3">Select a time on ${(function () {
var p = slots[0].start.split("T")[0].split("-");
return parseInt(p[1]) + "/" + parseInt(p[2]) + "/" + p[0];
})()}:</p>
<div id="riverside-booking-slots">
${slots.map(function (slot) {
return html`
<button
key=${slot.id}
type="button"
onClick=${function () { handleSlotClick(slot); }}
class=${"riverside-slot-btn" + (selectedSlotId === slot.id ? " is-selected" : "")}
>${slotLabel(slot.start)}</button>
`;
})}
</div>
</div>
` : null}
</div>
${!success && selectedSlot ? html`
<form ref=${formRef} onSubmit=${handleSubmit} autocomplete="on" class=${CX.formSection}>
<p class=${CX.formHeading}>Your Details</p>
<div class=${CX.formGrid}>
<div>
<label class=${CX.formLabel} for="first-name">
First (or given) name <span class=${CX.formRequired}>*</span>
</label>
<input
id="first-name"
type="text"
name="first_name"
autocomplete="given-name"
required
value=${formData.firstName}
onInput=${function (e) { handleFormChange("firstName", e.target.value); }}
class=${CX.formInput}
/>
</div>
<div>
<label class=${CX.formLabel} for="last-name">
Last (or family) name <span class=${CX.formRequired}>*</span>
</label>
<input
id="last-name"
type="text"
name="last_name"
autocomplete="family-name"
required
value=${formData.lastName}
onInput=${function (e) { handleFormChange("lastName", e.target.value); }}
class=${CX.formInput}
/>
</div>
<div class="sm:col-span-2">
<label class=${CX.formLabel} for="email">
Email address <span class=${CX.formRequired}>*</span>
</label>
<input
id="email"
type="email"
name="email"
autocomplete="email"
required
value=${formData.email}
onInput=${function (e) { handleFormChange("email", e.target.value); }}
class=${CX.formInput}
/>
</div>
<div class="sm:col-span-2">
<label class=${CX.formLabel} for="phone">
Phone number <span class=${CX.formRequired}>*</span>
</label>
<input
id="phone"
type="tel"
name="phone"
autocomplete="tel"
required
value=${formatPhone(formData.phone)}
onInput=${function (e) {
handleFormChange("phone", formatPhone(e.target.value));
}}
class=${CX.formInput}
/>
</div>
</div>
<div class="mb-6">
<label class=${CX.formLabel} for="comments">
Comments
</label>
<textarea
id="comments"
rows="4"
name="comments"
autocomplete="off"
value=${formData.comments}
onInput=${function (e) { handleFormChange("comments", e.target.value); }}
class=${CX.formTextarea}
></textarea>
</div>
${submitError ? html`<p class=${CX.formError}>${submitError}</p>` : null}
<button type="submit" disabled=${submitting} class=${CX.submitBtn}>
${submitting ? "Submitting…" : "Request appointment"}
</button>
</form>
` : null}
` : null}
${success && confirmedAppointment ? html`
<div ref=${successRef} class=${CX.successSection}>
<div class=${CX.successBox}>
<p class=${CX.successTitle}>Request received!</p>
<div class=${CX.successSummary}>
<p>${confirmedAppointment.firstName} ${confirmedAppointment.lastName}</p>
<p>${confirmedAppointment.email}</p>
<p>${TYPES.find(function (t) { return t.id === confirmedAppointment.service; }).label}</p>
<p>${formatAppointmentDate(confirmedAppointment.start)}</p>
</div>
<p class=${CX.successNote}>We'll contact you shortly to confirm your appointment.</p>
</div>
</div>
` : null}
</div>
`;
}
// ── Booking ───────────────────────────────────────────────────────────────
// Owns service selection and the type selector UI. Keys BookingPanel by
// service so it mounts fresh on every change — no reset effects, no races.
// Starts with null so no service is pre-selected.
function Booking({ settings }) {
const [service, setService] = useState(null);
return html`
<div style="min-height:460px">
<p class=${CX.selectorLabel}>Select Appointment Type</p>
<div class=${CX.selectorGrid}>
${TYPES.map(function (t) {
var active = service === t.id;
return html`
<button
key=${t.id}
onClick=${function () { setService(t.id); }}
style="text-align:left; cursor:pointer;"
class=${CX.typeBtn + " " + (active ? CX.typeBtnActive : CX.typeBtnInactive)}
>
<div class=${CX.typeCircle + " " + (active ? CX.typeCircleActive : CX.typeCircleInactive)}>
${active ? CHECK : null}
</div>
<div>
<p class=${CX.typeLabel + " " + (active ? CX.typeLabelActive : CX.typeLabelInactive)}>
${t.label}
</p>
<p class=${CX.typeDuration + " " + (active ? CX.typeDurationActive : CX.typeDurationInactive)}>
${t.duration}
</p>
</div>
</button>
`;
})}
</div>
${service ? html`
<${BookingPanel} key=${service} service=${service} settings=${settings} />
` : null}
</div>
`;
}
class RptBooking extends HTMLElement {
connectedCallback() {
render(html`<${Booking} settings=${window.drupalSettings.riversidePt} />`, this);
}
disconnectedCallback() {
render(null, this);
}
}
customElements.define("rpt-booking", RptBooking);

View file

@ -0,0 +1,85 @@
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";
const FAQS = [
{
q: "What should I expect at my first appointment?",
a: "Your first visit is a comprehensive diagnostic assessment lasting about an hour. We evaluate your movement, strength, pain points, and medical history to build a personalized treatment plan before any hands-on therapy begins.",
},
{
q: "Do I need a referral from my doctor?",
a: "In most cases, no. Washington state allows direct access to physical therapy, so you can book an appointment without a physician referral. However, some insurance plans require one -- check with your provider to be sure.",
},
{
q: "How long does a typical course of treatment last?",
a: "It depends on your condition and goals. Some patients see resolution in four to six weeks; others with surgical rehab or neurological conditions may work with us for several months. We set clear milestones at the start so you always know where you stand.",
},
{
q: "What insurances do you accept?",
a: "We accept most major insurance plans including Premera, Regence, Aetna, Cigna, and Medicare. We also offer self-pay rates. Contact us before your first appointment and we'll verify your coverage.",
},
{
q: "Can I book an appointment online?",
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?",
a: "Wear comfortable, loose-fitting clothing that allows easy access to the area being treated. Bring a photo ID, your insurance card, and any relevant imaging (X-rays, MRI reports) on your first visit.",
},
];
function FaqItem({ item, open, onToggle }) {
return html`
<div class="border-b border-gray-200">
<button
onClick=${onToggle}
class="w-full flex items-center justify-between gap-6 py-5 text-left cursor-pointer bg-transparent"
aria-expanded=${open}
>
<span class="text-[1.0625rem] text-gray-900">${item.q}</span>
<span class="shrink-0 text-xl text-gray-400 leading-none" style=${{ display: "inline-block", transform: open ? "rotate(45deg)" : "none", transition: "transform 0.2s ease" }}>+</span>
</button>
<div style=${{ display: "grid", gridTemplateRows: open ? "1fr" : "0fr", transition: "grid-template-rows 0.25s ease" }}>
<div style="overflow:hidden">
<p class="text-[15px] text-gray-500 leading-relaxed pb-5">${item.a}</p>
</div>
</div>
</div>
`;
}
function Faq() {
const [openIndex, setOpenIndex] = useState(null);
const toggle = function(i) {
setOpenIndex(function(prev) { return prev === i ? null : i; });
};
return html`
<div class="py-20 px-6 bg-white">
<h2 class="text-[clamp(3.5rem,8vw,6rem)] font-serif font-normal text-gray-900 text-center mb-16 leading-none">FAQ</h2>
<div class="max-w-[860px] mx-auto border-t border-gray-200">
${FAQS.map((item, i) => html`
<${FaqItem}
key=${i}
item=${item}
open=${openIndex === i}
onToggle=${function() { toggle(i); }}
/>
`)}
</div>
</div>
`;
}
class RptFaq extends HTMLElement {
connectedCallback() {
render(html`<${Faq} />`, this);
}
disconnectedCallback() {
render(null, this);
}
}
customElements.define("rpt-faq", RptFaq);

View file

@ -0,0 +1,164 @@
import { h, render } from "https://esm.sh/preact@10";
import { useState, useEffect, useReducer, useRef } from "https://esm.sh/preact@10/hooks";
import { html } from "https://esm.sh/htm@3/preact";
const TESTIMONIALS = [
{
name: "Sarah M.", category: "Sports Rehab Patient", initials: "SM", bgClass: "bg-pt-blue-400",
quote: "After my ACL tear I was terrified I'd never run again. The team here built a plan that had me back on the field in four months. Every session felt purposeful.",
},
{
name: "Leon N.", category: "Neurology Patient", initials: "LN", bgClass: "bg-pt-blue-300",
quote: "Every new patient begins with a comprehensive diagnostic assessment. From there, they create a fully personalized treatment plan -- whether that means returning to sport, recovering from surgery, or restoring function.",
},
{
name: "Diana K.", category: "Surgery Rehab Patient", initials: "DK", bgClass: "bg-pt-sage-400",
quote: "Six weeks post-hip replacement and I was walking without a cane -- weeks ahead of what my surgeon expected. The therapists here are genuinely invested in your outcome, not just checking boxes.",
},
{
name: "Marcus T.", category: "Sports Rehab Patient", initials: "MT", bgClass: "bg-pt-sage-500",
quote: "I came in with chronic shoulder pain that three other clinics couldn't resolve. Two months in, I'm lifting overhead for the first time in years. The diagnostic process here is legitimately different.",
},
{
name: "Rachel O.", category: "Surgery Rehab Patient", initials: "RO", bgClass: "bg-pt-blue-300",
quote: "The booking process is seamless and the staff remembers you. I never felt like just another patient. My recovery from rotator cuff surgery exceeded every milestone.",
},
{
name: "James P.", category: "Neurology Patient", initials: "JP", bgClass: "bg-pt-blue-400",
quote: "After my stroke the neurological therapy program here gave me my independence back. The team combined manual therapy with targeted exercise in a way that made real, measurable progress every single week.",
},
];
const CARD_W = 270;
const GAP = 20;
const STEP = CARD_W + GAP;
const TOTAL_W = TESTIMONIALS.length * CARD_W + (TESTIMONIALS.length - 1) * GAP;
function Testimonials() {
const containerRef = useRef(null);
const trackRef = useRef(null);
const [left, setLeft] = useState(0);
const [, forceUpdate] = useReducer(function (n) { return n + 1; }, 0);
function measureMax() {
if (!containerRef.current) return 0;
return Math.max(0, TOTAL_W - containerRef.current.offsetWidth);
}
var prev = function () {
setLeft(function (l) { return Math.min(0, l + STEP); });
};
var next = function () {
var maxL = measureMax();
setLeft(function (l) { return Math.max(-maxL, l - STEP); });
};
useEffect(function () {
var timer;
function onResize() {
clearTimeout(timer);
timer = setTimeout(function () {
var max = measureMax();
setLeft(function (l) { return Math.min(0, Math.max(-max, l)); });
forceUpdate();
}, 150);
}
window.addEventListener("resize", onResize);
return function () {
clearTimeout(timer);
window.removeEventListener("resize", onResize);
};
}, []);
var drag = useRef(null); // null when idle, {x, left} when dragging
var onPointerDown = function (e) {
drag.current = { x: e.clientX, left: left };
trackRef.current.style.transition = "none";
e.currentTarget.setPointerCapture(e.pointerId);
};
var onPointerMove = function (e) {
if (!drag.current) return;
setLeft(drag.current.left + (e.clientX - drag.current.x));
};
var onPointerUp = function (e) {
if (!drag.current) return;
drag.current = null;
trackRef.current.style.transition = "left 0.5s ease";
var max = measureMax();
setLeft(function (l) {
var clamped = Math.min(0, Math.max(-max, l));
var snapped = -Math.round(-clamped / STEP) * STEP;
return Math.max(-max, snapped);
});
};
var atStart = left >= 0;
var atEnd = !!containerRef.current && left <= -measureMax();
return html`
<div style="overflow:hidden">
<div class="px-6 py-16">
<div ref=${containerRef} style="max-width:1200px; margin:0 auto">
<div class="mb-10">
<p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold mb-4">Testimonials</p>
<div class="flex items-end gap-6">
<h2 class="text-[clamp(1.75rem,3vw,2.5rem)] font-serif font-normal text-gray-900 leading-tight max-w-[520px]">
Don${String.fromCharCode(8217)}t take our word for it.<br />Hear it from our patients!
</h2>
<div class="flex gap-3 pb-1 shrink-0">
<button
onClick=${prev}
disabled=${atStart}
aria-label="Previous testimonials"
class="w-10 h-10 rounded-full border border-gray-300 flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors disabled:opacity-30"
>${String.fromCharCode(8592)}</button>
<button
onClick=${next}
disabled=${atEnd}
aria-label="Next testimonials"
class="w-10 h-10 rounded-full border border-gray-300 flex items-center justify-center text-gray-500 hover:bg-gray-100 transition-colors disabled:opacity-30"
>${String.fromCharCode(8594)}</button>
</div>
</div>
</div>
<div
ref=${trackRef}
onPointerDown=${onPointerDown}
onPointerMove=${onPointerMove}
onPointerUp=${onPointerUp}
onPointerCancel=${onPointerUp}
style=${{ position: "relative", top: 0, left: left + "px", transition: "left 0.5s ease", display: "flex", gap: GAP + "px", paddingBottom: "2px", width: TOTAL_W + "px", touchAction: "pan-y" }}
>
${TESTIMONIALS.map(function (t, i) { return html`
<div key=${i} style=${{ width: CARD_W + "px", flexShrink: 0 }} class="border border-gray-200 rounded-lg p-6 flex flex-col gap-5 bg-white">
<div
class=${"w-14 h-14 rounded-full flex items-center justify-center text-white font-semibold text-base shrink-0 " + t.bgClass}
>${t.initials}</div>
<p class="text-[15px] text-gray-700 leading-relaxed flex-1">${t.quote}</p>
<div>
<p class="text-xl font-serif text-gray-900 mb-0.5">${t.name}</p>
<p class="text-xs tracking-widest uppercase text-pt-blue-500 font-semibold">${t.category}</p>
</div>
</div>
`; })}
</div>
</div>
</div>
</div>
`;
}
class RptTestimonials extends HTMLElement {
connectedCallback() {
render(html`<${Testimonials} />`, this);
}
disconnectedCallback() {
render(null, this);
}
}
customElements.define("rpt-testimonials", RptTestimonials);

View file

@ -11,8 +11,8 @@ function Toggle({ label = 'Toggle' }) {
setChecked(c => !c) setChecked(c => !c)
}} }}
aria-pressed=${checked} aria-pressed=${checked}
class=${`flex items-center gap-2 px-4 py-2 rounded-full border-2 border-[#306f8e] text-sm font-medium cursor-pointer transition-colors ${ class=${`flex items-center gap-2 px-4 py-2 rounded-full border-2 border-pt-blue-500 text-sm font-medium cursor-pointer transition-colors ${
checked ? 'bg-[#306f8e] text-white' : 'bg-transparent text-[#306f8e]' checked ? 'bg-pt-blue-500 text-white' : 'bg-transparent text-pt-blue-500'
}`} }`}
> >
<span class="w-3 h-3 rounded-full bg-current"></span> <span class="w-3 h-3 rounded-full bg-current"></span>

File diff suppressed because one or more lines are too long

View file

@ -13,6 +13,7 @@
nav.querySelectorAll('a').forEach(function (link) { nav.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function () { link.addEventListener('click', function () {
if (link.dataset.scrollTo) return; // page scrolls away — no need to close
nav.classList.remove('is-open'); nav.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-expanded', 'false');
}); });

View file

@ -0,0 +1,66 @@
(function () {
function formatPhone(raw) {
let d = String(raw || "").replace(/\D/g, "");
if (d.length === 11 && d[0] === "1") {
// NANP with leading 1: show "1 (xxx) xxx-xxxx"
const rest = d.slice(1);
return "1 (" + rest.slice(0, 3) + ") " + rest.slice(3, 6) + "-" + rest.slice(6);
}
d = d.slice(0, 10);
if (d.length === 0) return "";
if (d.length <= 3) return d;
if (d.length <= 6) return "(" + d.slice(0, 3) + ") " + d.slice(3);
return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6);
}
function enhancePhoneInput(input) {
if (input.dataset.phoneEnhanced) return;
input.dataset.phoneEnhanced = "true";
input.addEventListener("input", function () {
const oldValue = input.value;
const oldStart = input.selectionStart || 0;
const formatted = formatPhone(input.value);
if (oldValue !== formatted) {
input.value = formatted;
// Try to keep cursor relative to the digit sequence the user was editing.
const oldDigitsBefore = (oldValue.slice(0, oldStart).match(/\d/g) || []).length;
let newPos = 0;
let digitsSeen = 0;
for (; newPos < formatted.length; newPos++) {
if (/\d/.test(formatted[newPos])) {
digitsSeen++;
}
if (digitsSeen >= oldDigitsBefore) {
newPos++;
break;
}
}
if (newPos > formatted.length) newPos = formatted.length;
try {
input.setSelectionRange(newPos, newPos);
} catch (_) {}
}
});
// Format any server-provided default value on load (e.g. prefilled from tempstore)
if (input.value) {
const f = formatPhone(input.value);
if (input.value !== f) input.value = f;
}
}
function scan() {
document.querySelectorAll("input.rpt-phone").forEach(enhancePhoneInput);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", scan);
} else {
scan();
}
})();

View file

@ -0,0 +1,44 @@
var FIXED_BUFFER = 0; // breathing room below fixed header when menu is closed
// Returns the effective scroll offset to clear the header.
// When the hamburger is open, offsetHeight already includes the expanded nav,
// so no extra buffer is needed. When closed, add FIXED_BUFFER for breathing room.
function headerOffset() {
var header = document.querySelector(".rpt-header");
if (!header) return 0;
var pos = window.getComputedStyle(header).position;
if (pos !== "fixed" && pos !== "sticky") return 0;
var nav = document.getElementById("rpt-main-nav");
var menuOpen = nav && nav.classList.contains("is-open");
return header.offsetHeight + (menuOpen ? 0 : FIXED_BUFFER);
}
function scrollToEl(el, animate) {
var top = Math.max(0, el.getBoundingClientRect().top + window.scrollY - headerOffset());
window.scrollTo({ top: top, behavior: animate ? "smooth" : "instant" });
}
window.rptScrollTo = scrollToEl;
// On load: scroll to the anchor from either the URL hash or drupalSettings
// (set by the server when rendering the home page at a clean URL like /services).
document.addEventListener("DOMContentLoaded", function () {
var settings = window.drupalSettings && window.drupalSettings.riversidePt;
var anchor = window.location.hash || (settings && settings.scrollTo);
if (!anchor) return;
var target = document.querySelector(anchor);
if (!target) return;
requestAnimationFrame(function () {
scrollToEl(target, false);
});
});
document.addEventListener("click", function (e) {
var link = e.target.closest("[data-scroll-to]");
if (!link) return;
var target = document.querySelector(link.dataset.scrollTo);
if (!target) return;
e.preventDefault();
history.pushState({}, "", link.getAttribute("href"));
scrollToEl(target, true);
});

View file

@ -8,8 +8,44 @@ use Drupal\user\Entity\Role;
use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldConfig;
/**
* Implements hook_install().
*
* Delegates to the rebuild function so that normal module installs and
* container rebuilds both produce the same result.
*/
function riverside_pt_install() { function riverside_pt_install() {
// Pass 1: types, roles, and field storages (no inter-dependencies). _riverside_pt_rebuild();
}
/**
* Rebuilds the Riverside PT site structure from code.
*
* This function is idempotent and can be safely called multiple times.
* It is the single source of truth for content types, fields, roles,
* and navigation used by the Docker entrypoint.
*/
function _riverside_pt_rebuild(): void {
// Clear any stale semaphore locks. This prevents duplicate key violations
// on the "semaphore" table (e.g. "state:Drupal\Core\Cache\CacheCollector",
// "cron") during rebuilds. These occur because rapid entity/config
// changes trigger cache collectors and lock acquisitions concurrently.
// Safe to run even on initial install (table may not exist yet).
try {
\Drupal::database()->truncate('semaphore')->execute();
}
catch (\Exception $e) {
// Ignore if table doesn't exist yet.
}
$entity_type_manager = \Drupal::entityTypeManager();
$storage_node_type = $entity_type_manager->getStorage('node_type');
$storage_role = $entity_type_manager->getStorage('user_role');
$storage_field_storage = $entity_type_manager->getStorage('field_storage_config');
$storage_field_config = $entity_type_manager->getStorage('field_config');
// --- Content types (idempotent) ---
if (!$storage_node_type->load('appointment')) {
NodeType::create([ NodeType::create([
'type' => 'appointment', 'type' => 'appointment',
'name' => 'Appointment', 'name' => 'Appointment',
@ -17,7 +53,9 @@ function riverside_pt_install() {
'new_revision' => FALSE, 'new_revision' => FALSE,
'display_submitted' => FALSE, 'display_submitted' => FALSE,
])->save(); ])->save();
}
if (!$storage_node_type->load('provider_availability')) {
NodeType::create([ NodeType::create([
'type' => 'provider_availability', 'type' => 'provider_availability',
'name' => 'Provider Availability', 'name' => 'Provider Availability',
@ -25,24 +63,25 @@ function riverside_pt_install() {
'new_revision' => FALSE, 'new_revision' => FALSE,
'display_submitted' => FALSE, 'display_submitted' => FALSE,
])->save(); ])->save();
}
// --- Role (idempotent) ---
if (!$storage_role->load('provider')) {
Role::create(['id' => 'provider', 'label' => 'Provider'])->save(); Role::create(['id' => 'provider', 'label' => 'Provider'])->save();
}
FieldStorageConfig::create([ // --- Field storages (idempotent) ---
'field_name' => 'field_appointment_date', $field_storages = [
'field_appointment_date' => [
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'datetime', 'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'], 'settings' => ['datetime_type' => 'datetime'],
])->save(); ],
'field_duration_minutes' => [
FieldStorageConfig::create([
'field_name' => 'field_duration_minutes',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'integer', 'type' => 'integer',
])->save(); ],
'field_service_type' => [
FieldStorageConfig::create([
'field_name' => 'field_service_type',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'list_string', 'type' => 'list_string',
'settings' => [ 'settings' => [
@ -53,58 +92,60 @@ function riverside_pt_install() {
'neurological_pt' => 'Neurological PT', 'neurological_pt' => 'Neurological PT',
], ],
], ],
])->save(); ],
'field_provider' => [
FieldStorageConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'entity_reference', 'type' => 'entity_reference',
'settings' => ['target_type' => 'user'], 'settings' => ['target_type' => 'user'],
])->save(); ],
'field_start_datetime' => [
FieldStorageConfig::create([
'field_name' => 'field_start_datetime',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'datetime', 'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'], 'settings' => ['datetime_type' => 'datetime'],
])->save(); ],
'field_end_datetime' => [
FieldStorageConfig::create([
'field_name' => 'field_end_datetime',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'datetime', 'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'], 'settings' => ['datetime_type' => 'datetime'],
])->save(); ],
];
// Clear field definition cache so FieldConfig::preSave() can find the storages. foreach ($field_storages as $field_name => $definition) {
if (!$storage_field_storage->load("node.$field_name")) {
FieldStorageConfig::create([
'field_name' => $field_name,
] + $definition)->save();
}
}
// Clear field definition cache so FieldConfig creation can see the storages.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Pass 2: field configs (depend on storages being in the DB). // --- Field configs (idempotent) ---
FieldConfig::create([ $field_configs = [
// Appointment bundle
'node.appointment.field_appointment_date' => [
'field_name' => 'field_appointment_date', 'field_name' => 'field_appointment_date',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'appointment', 'bundle' => 'appointment',
'label' => 'Appointment Date', 'label' => 'Appointment Date',
'required' => TRUE, 'required' => TRUE,
])->save(); ],
'node.appointment.field_duration_minutes' => [
FieldConfig::create([
'field_name' => 'field_duration_minutes', 'field_name' => 'field_duration_minutes',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'appointment', 'bundle' => 'appointment',
'label' => 'Duration (Minutes)', 'label' => 'Duration (Minutes)',
'required' => TRUE, 'required' => TRUE,
])->save(); ],
'node.appointment.field_service_type' => [
FieldConfig::create([
'field_name' => 'field_service_type', 'field_name' => 'field_service_type',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'appointment', 'bundle' => 'appointment',
'label' => 'Service Type', 'label' => 'Service Type',
'required' => TRUE, 'required' => TRUE,
])->save(); ],
'node.appointment.field_provider' => [
FieldConfig::create([
'field_name' => 'field_provider', 'field_name' => 'field_provider',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'appointment', 'bundle' => 'appointment',
@ -116,9 +157,9 @@ function riverside_pt_install() {
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']], 'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
], ],
], ],
])->save(); ],
// Provider availability bundle
FieldConfig::create([ 'node.provider_availability.field_provider' => [
'field_name' => 'field_provider', 'field_name' => 'field_provider',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'provider_availability', 'bundle' => 'provider_availability',
@ -130,24 +171,30 @@ function riverside_pt_install() {
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']], 'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
], ],
], ],
])->save(); ],
'node.provider_availability.field_start_datetime' => [
FieldConfig::create([
'field_name' => 'field_start_datetime', 'field_name' => 'field_start_datetime',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'provider_availability', 'bundle' => 'provider_availability',
'label' => 'Start', 'label' => 'Start',
'required' => TRUE, 'required' => TRUE,
])->save(); ],
'node.provider_availability.field_end_datetime' => [
FieldConfig::create([
'field_name' => 'field_end_datetime', 'field_name' => 'field_end_datetime',
'entity_type' => 'node', 'entity_type' => 'node',
'bundle' => 'provider_availability', 'bundle' => 'provider_availability',
'label' => 'End', 'label' => 'End',
'required' => TRUE, 'required' => TRUE,
])->save(); ],
];
foreach ($field_configs as $field_id => $definition) {
if (!$storage_field_config->load($field_id)) {
FieldConfig::create($definition)->save();
}
}
// Navigation and basic site settings (these are intentionally destructive on menu).
try { try {
_riverside_pt_build_navigation(); _riverside_pt_build_navigation();
} }
@ -156,6 +203,8 @@ function riverside_pt_install() {
} }
\Drupal::configFactory()->getEditable('system.site') \Drupal::configFactory()->getEditable('system.site')
->set('name', 'Riverside Therapeutics')
->set('mail', 'info@coldairnetworks.com')
->set('page.front', '/home') ->set('page.front', '/home')
->save(); ->save();
} }
@ -167,18 +216,18 @@ function _riverside_pt_build_navigation(): void {
$link->delete(); $link->delete();
} }
foreach (['Services', 'About', 'FAQ', 'Contact'] as $title) { // Clean up legacy nodes for pages we now serve via custom controllers/routes
if ($em->getStorage('node')->loadByProperties(['title' => $title, 'type' => 'page'])) { foreach (['About', 'Contact'] as $title) {
continue; $nodes = $em->getStorage('node')->loadByProperties(['title' => $title, 'type' => 'page']);
foreach ($nodes as $node) {
$node->delete();
} }
$node = Node::create(['type' => 'page', 'title' => $title, 'status' => 1]); $aliases = $em->getStorage('path_alias')->loadByProperties(['alias' => '/' . strtolower($title)]);
$node->save(); foreach ($aliases as $alias) {
PathAlias::create([ $alias->delete();
'path' => '/node/' . $node->id(),
'alias' => '/' . strtolower($title),
'langcode' => 'en',
])->save();
} }
}
$defs = [ $defs = [
['title' => 'Home', 'uri' => 'route:<front>', 'weight' => 0, 'class' => NULL], ['title' => 'Home', 'uri' => 'route:<front>', 'weight' => 0, 'class' => NULL],
@ -187,7 +236,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:/book-appointment', 'weight' => 5, 'class' => 'nav-cta nav-cta--primary'],
]; ];
foreach ($defs as $def) { foreach ($defs as $def) {

View file

@ -4,14 +4,18 @@ app:
css/app.css: {} css/app.css: {}
js: js:
js/nav.js: {} js/nav.js: {}
js/scroll.js: { attributes: { type: module } }
js/phone-format.js: {}
js/components/rpt-toggle.js: { attributes: { type: module } } js/components/rpt-toggle.js: { attributes: { type: module } }
js/components/rpt-carousel.js: { attributes: { type: module } } js/components/rpt-carousel.js: { attributes: { type: module } }
js/components/rpt-testimonials.js: { attributes: { type: module } }
js/components/rpt-faq.js: { attributes: { type: module } }
schedule: schedule:
css: css:
theme: theme:
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 } }
dependencies: dependencies:
- core/drupalSettings - core/drupalSettings

View file

@ -5,6 +5,10 @@ use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Routing\RouteMatchInterface;
function riverside_pt_page_attachments(array &$attachments): void { function riverside_pt_page_attachments(array &$attachments): void {
$route = \Drupal::routeMatch()->getRouteName() ?? '';
if (!str_starts_with($route, 'riverside_pt.')) {
return;
}
$attachments['#attached']['library'][] = 'riverside_pt/app'; $attachments['#attached']['library'][] = 'riverside_pt/app';
} }
@ -21,13 +25,33 @@ function riverside_pt_theme(): array {
'riverside_pt_home' => [ 'riverside_pt_home' => [
'variables' => [], 'variables' => [],
], ],
'riverside_pt_about' => [
'variables' => [],
],
'riverside_pt_service' => [
'variables' => [
'slug' => NULL,
'title' => NULL,
'description' => NULL,
'long_description' => NULL,
'what_to_expect' => NULL,
'benefits' => [],
],
],
'riverside_pt_contact' => [
'variables' => [],
],
]; ];
} }
function riverside_pt_page_top(array &$page_top): void { function riverside_pt_page_top(array &$page_top): void {
$route = \Drupal::routeMatch()->getRouteName() ?? '';
if (!str_starts_with($route, 'riverside_pt.')) {
return;
}
$page_top['rpt_header'] = [ $page_top['rpt_header'] = [
'#theme' => 'riverside_pt_header', '#theme' => 'riverside_pt_header',
'#cache' => ['contexts' => ['url.path']], '#cache' => ['contexts' => ['url.path', 'route']],
]; ];
} }
@ -66,26 +90,25 @@ 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') { $service_map = [
return; 'diagnostic' => 'Diagnostic Assessment',
} 'sports' => 'Sports Rehabilitation',
'surgical' => 'Surgery Rehabilitation',
'neuro' => 'Neurological Therapy',
];
$service_label = $service_map[$params['service'] ?? ''] ?? 'Appointment';
if ($key === 'booking_request') {
$start = new \DateTime($params['start']); $start = new \DateTime($params['start']);
$end = new \DateTime($params['end']); $end = new \DateTime($params['end']);
$message['subject'] = 'Booking request — ' . $start->format('M j, Y g:i A'); $message['subject'] = 'Booking request — ' . $start->format('M j, Y g:i A');
$lines = [ $lines = [
'Name: ' . $params['first_name'] . ' ' . $params['last_name'], 'Name: ' . ($params['first_name'] ?? '') . ' ' . ($params['last_name'] ?? ''),
'Phone: ' . $params['phone'], 'Email: ' . ($params['email'] ?? ''),
'Service: ' . $service_label,
'Phone: ' . ($params['phone'] ?? ''),
'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '' . $end->format('g:i A'), 'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '' . $end->format('g:i A'),
]; ];
@ -94,4 +117,38 @@ function riverside_pt_mail(string $key, array &$message, array $params): void {
} }
$message['body'][] = implode("\n", $lines); $message['body'][] = implode("\n", $lines);
return;
}
if ($key === 'booking_confirmation') {
$start = new \DateTime($params['start']);
$end = new \DateTime($params['end']);
$first = $params['first_name'] ?? 'Patient';
$message['subject'] = 'Your appointment is confirmed — ' . $start->format('M j, Y g:i A');
$lines = [
'Dear ' . $first . ',',
'',
'Your appointment is *confirmed* for:',
$start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '' . $end->format('g:i A') . ' PST',
'',
'Service: ' . $service_label,
];
$full_name = trim(($params['first_name'] ?? '') . ' ' . ($params['last_name'] ?? ''));
if ($full_name) {
$lines[] = 'Name: ' . $full_name;
}
if (!empty($params['phone'])) {
$lines[] = 'Phone: ' . $params['phone'];
}
$lines[] = '';
$lines[] = 'We look forward to seeing you at Riverside Physical Therapy.';
$lines[] = 'If you need to cancel or reschedule, please contact us as soon as possible.';
$message['body'][] = implode("\n", $lines);
return;
}
} }

View file

@ -1,24 +1,16 @@
riverside_pt.palette:
path: '/dev/palette'
defaults:
_controller: '\Drupal\riverside_pt\Controller\PaletteController::page'
_title: 'Color Palette'
requirements:
_permission: 'administer site configuration'
riverside_pt.home: riverside_pt.home:
path: '/home' path: '/home'
defaults: defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::page' _controller: '\Drupal\riverside_pt\Controller\HomeController::page'
_title: 'Riverside Physical Therapy' _title: 'Welcome'
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: requirements:
_permission: 'access content' _permission: 'access content'
@ -39,3 +31,60 @@ riverside_pt.schedule_events:
options: options:
_auth: _auth:
- cookie - cookie
riverside_pt.about:
path: '/about'
defaults:
_controller: '\Drupal\riverside_pt\Controller\PageController::page'
_title: 'About'
requirements:
_permission: 'access content'
riverside_pt.services:
path: '/services'
defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::redirectToAnchor'
destination: '/home#pt-services'
requirements:
_access: 'TRUE'
riverside_pt.faq:
path: '/faq'
defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::redirectToAnchor'
destination: '/home#pt-faq'
requirements:
_access: 'TRUE'
riverside_pt.book_appointment:
path: '/book-appointment'
defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::redirectToAnchor'
destination: '/home#book-an-appointment'
requirements:
_access: 'TRUE'
riverside_pt.testimonials:
path: '/testimonials'
defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::redirectToAnchor'
destination: '/home#pt-testimonials'
requirements:
_access: 'TRUE'
riverside_pt.service:
path: '/services/{slug}'
defaults:
_controller: '\Drupal\riverside_pt\Controller\PageController::service'
_title: 'Service'
requirements:
_permission: 'access content'
slug: 'diagnostic-assessment|sports-rehabilitation|pre-post-surgical-rehab|neurological-therapy'
riverside_pt.contact:
path: '/contact'
defaults:
_controller: '\Drupal\riverside_pt\Controller\PageController::contact'
_title: 'Contact'
requirements:
_permission: 'access content'

View file

@ -0,0 +1,5 @@
services:
riverside_pt.php_error_logger:
class: Drupal\riverside_pt\Logger\PhpErrorLogger
tags:
- { name: logger }

View file

@ -0,0 +1,43 @@
<?php
namespace Drupal\riverside_pt\Commands;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Drush commands for Riverside PT site management.
*/
final class RiversidePtCommands extends DrushCommands implements ContainerInjectionInterface {
public static function create(ContainerInterface $container): self {
return new self();
}
/**
* Rebuilds the entire Riverside PT site structure from code.
*
* This is the command used by the Docker entrypoint on every start
* (unless DRUPAL_FAST=1 is passed).
*/
#[CLI\Command(name: 'riverside:rebuild', aliases: ['rrb'])]
#[CLI\Usage(name: 'drush riverside:rebuild', description: 'Rebuild content types, fields, roles, and navigation from code. Safe to run repeatedly.')]
public function rebuild(): void {
$this->output()->writeln('<info>Rebuilding Riverside PT site structure from code...</info>');
// Make the helper functions from riverside_pt.install available.
\Drupal::moduleHandler()->loadInclude('riverside_pt', 'install');
if (!function_exists('_riverside_pt_rebuild')) {
$this->logger()->error('Could not load _riverside_pt_rebuild().');
return;
}
_riverside_pt_rebuild();
$this->output()->writeln('<info>Rebuild complete.</info>');
$this->logger()->success('Riverside PT structure has been rebuilt from code.');
}
}

View file

@ -3,11 +3,44 @@
namespace Drupal\riverside_pt\Controller; namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\Request;
class HomeController extends ControllerBase { class HomeController extends ControllerBase {
// Renders the home page at a clean URL (e.g. /services, /book-appointment)
// and injects the scroll target so the client scrolls to the right section
// without a redirect — the URL stays exactly as requested.
public function redirectToAnchor(Request $request): array {
$build = $this->page();
$destination = $request->attributes->get('destination', '');
$hash = strstr($destination, '#');
if ($hash !== FALSE) {
$build['#attached']['drupalSettings']['riversidePt']['scrollTo'] = $hash;
}
return $build;
}
public function page(): array { public function page(): array {
return ['#theme' => 'riverside_pt_home']; $holidays = $this->config('riverside_pt.settings')->get('holidays') ?? [];
$holidayMap = [];
foreach ($holidays as $h) {
$holidayMap[$h['date']] = $h['name'];
}
return [
'#theme' => 'riverside_pt_home',
'#attached' => [
'library' => ['riverside_pt/schedule'],
'drupalSettings' => [
'riversidePt' => [
'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
'holidays' => $holidayMap,
],
],
],
];
} }
} }

View file

@ -0,0 +1,111 @@
<?php
namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase;
class PageController extends ControllerBase {
public function page(): array {
return [
'#theme' => 'riverside_pt_about',
'#cache' => ['max-age' => 0],
];
}
public function contact(): array {
return [
'#theme' => 'riverside_pt_contact',
'#cache' => ['max-age' => 0],
];
}
public function service($slug): array {
$services = $this->getServices();
$service = $services[$slug] ?? NULL;
if (!$service) {
throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException();
}
return [
'#theme' => 'riverside_pt_service',
'#slug' => $slug,
'#title' => $service['title'],
'#description' => $service['description'],
'#long_description' => $service['long_description'],
'#what_to_expect' => $service['what_to_expect'],
'#benefits' => $service['benefits'],
'#cache' => ['max-age' => 0],
];
}
private function getServices(): array {
return [
'diagnostic-assessment' => [
'title' => 'Diagnostic Assessment',
'description' => 'Your recovery starts with clarity. We perform a thorough evaluation of your condition, movement, and goals to create a precise, personalized treatment plan from day one.',
'long_description' => '<p>Our comprehensive diagnostic assessment is the foundation of effective physical therapy. During this 60-minute session, our expert therapists conduct a detailed evaluation including:</p>
<ul>
<li>Medical history review</li>
<li>Physical examination of movement patterns</li>
<li>Strength and flexibility testing</li>
<li>Postural and gait analysis</li>
<li>Specialized orthopedic tests</li>
</ul>
<p>This allows us to identify the root cause of your pain or limitation, not just the symptoms.</p>',
'what_to_expect' => '<p>You will be asked to perform various movements and exercises while we observe and measure. We may use hands-on techniques to assess joint mobility and soft tissue. Wear comfortable clothing that allows easy movement and access to the area being evaluated. The goal is to gather enough information to design a targeted treatment plan that addresses your specific needs and goals.</p>',
'benefits' => [
'Accurate identification of the source of your pain or dysfunction',
'Personalized treatment plan tailored to your body and lifestyle',
'Clear understanding of your condition and recovery timeline',
'Baseline measurements to track progress objectively',
'Prevention of future injuries through early detection of imbalances',
],
],
'sports-rehabilitation' => [
'title' => 'Sports Rehabilitation',
'description' => 'We help athletes recover from injury and return to peak performance with targeted, sport-specific programs built around your body and your goals.',
'long_description' => '<p>Whether you\'re a weekend warrior or a competitive athlete, our sports rehabilitation program is designed to get you back in the game safely and stronger than before. We combine evidence-based techniques with sport-specific training to address the unique demands of your activity.</p>
<p>Our therapists have experience working with athletes from a variety of sports including running, cycling, soccer, basketball, tennis, golf, and more.</p>',
'what_to_expect' => '<p>Treatment sessions focus on restoring mobility, strength, power, and coordination specific to your sport. We incorporate functional movements, plyometrics, agility drills, and sport-specific simulations. You will receive a customized home exercise program and guidance on return-to-sport criteria and injury prevention strategies.</p>',
'benefits' => [
'Faster, safer return to your sport or activity',
'Sport-specific strengthening and conditioning',
'Improved performance and biomechanics',
'Reduced risk of re-injury',
'Education on proper warm-up, recovery, and training principles',
],
],
'pre-post-surgical-rehab' => [
'title' => 'Pre/Post-Surgical Rehab',
'description' => 'Expert care before and after surgery to reduce recovery time, minimize complications, and restore full strength and function.',
'long_description' => '<p>Surgery is often just one step in the recovery journey. Our pre- and post-surgical rehabilitation programs are designed to optimize outcomes and get you back to your normal activities as quickly and safely as possible.</p>
<p>Pre-hab (pre-surgery rehab) can significantly improve post-op results by strengthening supporting muscles and improving range of motion before the procedure.</p>',
'what_to_expect' => '<p>Pre-surgery: We focus on maximizing strength, flexibility, and cardiovascular health to prepare your body for surgery and the demands of recovery. Post-surgery: We follow evidence-based protocols specific to your procedure (joint replacements, ACL reconstruction, rotator cuff repair, spinal surgery, etc.), progressing you through phases of healing while monitoring for any complications.</p>',
'benefits' => [
'Shorter hospital stays and faster initial recovery',
'Reduced post-operative pain and swelling',
'Restored range of motion and strength more quickly',
'Lower risk of complications such as blood clots or stiffness',
'Better long-term functional outcomes',
],
],
'neurological-therapy' => [
'title' => 'Neurological Therapy',
'description' => 'Specialized therapy for nervous system conditions — helping you rebuild strength, coordination, and independence at every stage of recovery.',
'long_description' => '<p>Neurological conditions such as stroke, Parkinson\'s disease, multiple sclerosis, traumatic brain injury, or spinal cord injury can significantly impact mobility, balance, and daily function. Our neurological physical therapy program uses specialized techniques to help you regain as much independence as possible.</p>
<p>We work closely with neurologists, occupational therapists, and other healthcare providers to create a coordinated care plan.</p>',
'what_to_expect' => '<p>Sessions may include balance and gait training, functional electrical stimulation, task-specific training, manual therapy, and exercises to improve strength, coordination, and proprioception. We also focus on fall prevention strategies and adaptive techniques to help you safely perform daily activities.</p>',
'benefits' => [
'Improved balance, coordination, and walking ability',
'Increased strength and endurance',
'Greater independence in daily living activities',
'Reduced fall risk',
'Better quality of life and confidence',
],
],
];
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase;
class PaletteController extends ControllerBase {
public function page(): array {
$colors = $this->parseColors();
if (!$colors) {
return ['#markup' => \Drupal\Core\Render\Markup::create('<p>Could not parse tailwind.config.js</p>')];
}
$html = '<div style="font-family:monospace;font-size:13px;padding:32px;background:#f5f5f5">';
foreach ($colors as $group => $shades) {
$html .= '<div style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#666;margin:28px 0 12px">'
. htmlspecialchars($group) . '</div>';
$html .= '<div style="display:flex;flex-wrap:wrap;gap:12px">';
foreach ($shades as $shade => $hex) {
$label = $shade === 'DEFAULT' ? $group : "$group-$shade";
$border = $this->luminance($hex) > 200 ? 'border:1px solid #ddd;' : '';
$html .= '<div style="width:100px">'
. "<div style=\"width:100px;height:64px;background:{$hex};border-radius:6px 6px 0 0;{$border}\"></div>"
. '<div style="background:#fff;border:1px solid #ddd;border-top:none;border-radius:0 0 6px 6px;padding:6px 8px">'
. '<div style="font-weight:600;color:#333">' . htmlspecialchars($label) . '</div>'
. '<div style="color:#888;font-size:11px">' . htmlspecialchars($hex) . '</div>'
. '</div>'
. '</div>';
}
$html .= '</div>';
}
$html .= '</div>';
return ['#markup' => \Drupal\Core\Render\Markup::create($html)];
}
private function parseColors(): array {
$path = dirname(DRUPAL_ROOT) . '/tailwind.config.js';
if (!file_exists($path)) {
return [];
}
$content = file_get_contents($path);
// Find the opening of the colors: { block
if (!preg_match('/colors\s*:\s*\{/', $content, $m, PREG_OFFSET_CAPTURE)) {
return [];
}
$start = $m[0][1] + strlen($m[0][0]);
// Walk forward counting braces to find the closing }
$depth = 1;
$i = $start;
$len = strlen($content);
while ($i < $len && $depth > 0) {
if ($content[$i] === '{') $depth++;
elseif ($content[$i] === '}') $depth--;
$i++;
}
$block = substr($content, $start, $i - $start - 1);
$colors = [];
// 'group': { shade: '#hex', ... }
preg_match_all("/'([^']+)'\s*:\s*\{([^}]+)\}/", $block, $groups, PREG_SET_ORDER);
foreach ($groups as $group) {
$name = $group[1];
preg_match_all('/(\w+)\s*:\s*\'(#[0-9a-fA-F]{3,6})\'/', $group[2], $shades, PREG_SET_ORDER);
foreach ($shades as $shade) {
$colors[$name][$shade[1]] = $shade[2];
}
}
// 'group': '#hex' (flat single-value entry)
preg_match_all("/'([^']+)'\s*:\s*'(#[0-9a-fA-F]{3,6})'/", $block, $singles, PREG_SET_ORDER);
foreach ($singles as $single) {
if (!isset($colors[$single[1]])) {
$colors[$single[1]]['DEFAULT'] = $single[2];
}
}
return $colors;
}
private function luminance(string $hex): int {
$hex = ltrim($hex, '#');
return (int) (
0.299 * hexdec(substr($hex, 0, 2)) +
0.587 * hexdec(substr($hex, 2, 2)) +
0.114 * hexdec(substr($hex, 4, 2))
);
}
}

View file

@ -3,93 +3,39 @@
namespace Drupal\riverside_pt\Controller; namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Flood\FloodInterface;
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;
class ScheduleController extends ControllerBase { class ScheduleController extends ControllerBase {
const FLOOD_IP_WINDOW = 3600; // 1 hour
const FLOOD_EMAIL_WINDOW = 86400; // 24 hours
private PrivateTempStore $tempStore; private PrivateTempStore $tempStore;
public function __construct(PrivateTempStoreFactory $tempStoreFactory) { public function __construct(
PrivateTempStoreFactory $tempStoreFactory,
private readonly MailManagerInterface $mailManager,
ConfigFactoryInterface $configFactory,
private readonly FloodInterface $flood,
) {
$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 [ $container->get('flood'),
'#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.'),
],
'calendar' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-calendar'],
],
'booking_backdrop' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-booking-backdrop', 'hidden' => TRUE],
'#value' => '',
],
'booking_panel' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'riverside-booking-panel', 'hidden' => TRUE],
'header' => [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['class' => ['riverside-booking-header']],
'title' => [
'#type' => 'html_tag',
'#tag' => 'span',
'#attributes' => ['id' => 'riverside-booking-date'],
'#value' => '',
],
'close' => [
'#type' => 'html_tag',
'#tag' => 'button',
'#attributes' => ['id' => 'riverside-booking-close', 'type' => 'button'],
'#value' => $this->t('✕'),
],
],
'slots' => [
'#type' => 'html_tag',
'#tag' => 'ul',
'#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 {
@ -100,10 +46,99 @@ 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'] ?? '');
$email = trim($data['email'] ?? '');
$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 && $email && $phone) {
$ip = $request->getClientIp();
if (!$this->flood->isAllowed('riverside_pt.booking_ip', 5, self::FLOOD_IP_WINDOW, $ip)) {
return new JsonResponse(['error' => 'rate_limited', 'message' => 'Too many requests. Please try again later.'], 429);
}
if (!$this->flood->isAllowed('riverside_pt.booking_email', 3, self::FLOOD_EMAIL_WINDOW, $email)) {
return new JsonResponse(['error' => 'rate_limited', 'message' => 'Too many requests. Please try again later.'], 429);
}
$this->flood->register('riverside_pt.booking_ip', self::FLOOD_IP_WINDOW, $ip);
$this->flood->register('riverside_pt.booking_email', self::FLOOD_EMAIL_WINDOW, $email);
// 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();
// Send confirmation to the user
$this->mailManager->mail('riverside_pt', 'booking_confirmation', $email, $lang, [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'phone' => $phone,
'comments' => $comments,
'start' => $start,
'end' => $end,
'service' => $service,
]);
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'phone' => $phone,
'comments' => $comments,
'start' => $start,
'end' => $end,
'service' => $service,
]);
$this->tempStore->delete('booking_slot');
if ($sent['result']) {
return new JsonResponse(['ok' => TRUE]);
}
\Drupal::logger('riverside_pt')->error('Booking request email failed to send to @to (user: @email)', [
'@to' => $to,
'@email' => $email,
]);
return new JsonResponse([
'error' => 'mail_failed',
'message' => 'We were unable to send the confirmation email. Please try again or contact us directly to book.',
], 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,
'provider_id' => $data['provider_id'] ?? '', 'service' => $service,
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'phone' => $phone,
'comments' => $comments,
'provider_id' => $providerId,
]); ]);
return new JsonResponse(['ok' => TRUE]); return new JsonResponse(['ok' => TRUE]);
@ -112,22 +147,49 @@ class ScheduleController extends ControllerBase {
public function events(Request $request): JsonResponse { public function events(Request $request): JsonResponse {
$start = $request->query->get('start'); $start = $request->query->get('start');
$end = $request->query->get('end'); $end = $request->query->get('end');
$service = $request->query->get('service', 'diagnostic');
$faultZeroAvailability = [
'diagnostic' => false,
'sports' => false,
'surgical' => false,
'neuro' => false,
];
if ($faultZeroAvailability[$service] ?? false) {
return new JsonResponse([]);
}
// Each service gets different slot density and start hours so calendars
// look meaningfully distinct when switching types.
$serviceConfig = [
'diagnostic' => ['seeds' => [5, 7, 11], 'startHour' => 9],
'sports' => ['seeds' => [3, 5, 8], 'startHour' => 7],
'surgical' => ['seeds' => [4, 6, 13], 'startHour' => 10],
'neuro' => ['seeds' => [2, 9, 7], 'startHour' => 11],
];
$cfg = $serviceConfig[$service] ?? $serviceConfig['diagnostic'];
[$s0, $s1, $s2] = $cfg['seeds'];
$current = new \DateTime($start ?? 'now'); $current = new \DateTime($start ?? 'now');
$today = new \DateTime('today'); $earliest = new \DateTime('tomorrow');
if ($current < $today) { if ($service === 'surgical') {
$current = $today; $earliest = new \DateTime('+46 days');
}
if ($current < $earliest) {
$current = $earliest;
} }
$until = new \DateTime($end ?? 'now'); $until = new \DateTime($end ?? 'now');
$events = []; $events = [];
$id = 1; $id = 1;
while ($current < $until) { while ($current < $until) {
$dow = (int) $current->format('N'); // 1=Mon … 7=Sun
if ($dow <= 5) {
$i = (int) floor($current->getTimestamp() / 86400); $i = (int) floor($current->getTimestamp() / 86400);
$count = ($i % 5 + $i % 7 + $i % 11) % 6; $count = ($i % $s0 + $i % $s1 + $i % $s2) % 6;
for ($n = 0; $n < $count; $n++) { for ($n = 0; $n < $count; $n++) {
$slot = clone $current; $slot = clone $current;
$slot->setTime(9 + $n, 0); $slot->setTime($cfg['startHour'] + $n, 0);
$events[] = [ $events[] = [
'id' => $id++, 'id' => $id++,
'title' => 'Available', 'title' => 'Available',
@ -135,6 +197,7 @@ class ScheduleController extends ControllerBase {
'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'); $current->modify('+1 day');
} }

View file

@ -1,155 +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,
];
$form['phone'] = [
'#type' => 'tel',
'#title' => $this->t('Phone number'),
'#required' => TRUE,
];
$form['comments'] = [
'#type' => 'textarea',
'#title' => $this->t('Comments'),
'#rows' => 4,
];
$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

@ -0,0 +1,23 @@
<?php
namespace Drupal\riverside_pt\Logger;
use Drupal\Core\Logger\RfcLogLevel;
use Psr\Log\LoggerInterface;
use Psr\Log\LoggerTrait;
class PhpErrorLogger implements LoggerInterface {
use LoggerTrait;
private const LABELS = ['emergency', 'alert', 'critical', 'error', 'warning'];
public function log($level, $message, array $context = []): void {
if ($level > RfcLogLevel::WARNING) {
return;
}
$channel = $context['channel'] ?? 'drupal';
$severity = self::LABELS[$level] ?? 'unknown';
$formatted = strtr($message, array_filter($context, 'is_scalar'));
error_log(sprintf('[drupal/%s] %s: %s', $channel, strtoupper($severity), $formatted));
}
}

View file

@ -0,0 +1,59 @@
<div class="bg-gradient-to-b from-pt-sage-400 to-pt-blue-400 pt-px">
<section class="relative md:mt-[78px] min-h-[320px] 2xl:flex">
<div class="absolute inset-0 flex sm:pt-8 sm:pb-8 2xl:static 2xl:inset-auto 2xl:w-[62%] 2xl:flex-none 2xl:p-0">
<div class="hidden sm:block sm:basis-[8%] sm:grow-[2] 2xl:hidden"></div>
<img src="/modules/custom/riverside_pt/images/neck.jpg" alt="Our clinic" class="basis-full sm:basis-[58%] sm:grow 2xl:basis-full 2xl:flex-none min-w-0 h-full object-cover object-center" />
<div class="hidden sm:block sm:basis-[34%] sm:grow-[2] 2xl:hidden"></div>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent sm:hidden"></div>
<div class="relative flex min-h-[320px] pt-0 pb-4 2xl:flex-1 2xl:bg-pt-blue-400 2xl:min-h-0 2xl:pb-0">
<div class="hidden sm:block sm:basis-[50%] sm:grow-[2] 2xl:hidden"></div>
<div class="basis-full sm:basis-[40%] sm:grow flex flex-col justify-end sm:justify-center px-6 sm:px-0 sm:pb-0 gap-[1vw] 2xl:basis-full 2xl:grow-0 2xl:pl-16 2xl:pr-12 2xl:justify-center">
<h1 class="mt-0 mb-[1vw] text-[clamp(1.5rem,3.5vw,3.25rem)] font-serif font-normal text-white leading-none [text-shadow:-56.21px_2.55px_10.22px_rgb(0_0_0/10%)]">
About Riverside Physical Therapy
</h1>
<p class="text-white/80 leading-tight text-[clamp(1rem,2vw,1.5vw)]">Helping you restore strength and reclaim your life since 2011.</p>
</div>
<div class="hidden sm:block sm:basis-[10%] sm:grow-[2] 2xl:hidden"></div>
</div>
</section>
</div>
<section class="rpt-section">
<div class="rpt-container">
<h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight mb-8 text-center">Our Story</h2>
<div class="max-w-none text-gray-700 space-y-4 text-[15px] leading-relaxed">
<p>Riverside Physical Therapy was founded in 2011 by Dr. Elena Morales, a passionate physical therapist who had grown frustrated with the one-size-fits-all approaches she saw in larger clinics. After years working in high-volume settings, Elena opened a small practice in a modest office on Riverside Drive with a single treatment table and a clear vision: every patient deserves a thorough, one-on-one evaluation followed by a truly personalized plan designed around their unique body, goals, and life.</p>
<p>What began with just a handful of patients quickly grew by word of mouth. People appreciated the time we took to listen, the detailed assessments that uncovered root causes rather than just treating symptoms, and the measurable progress they saw in their strength and mobility. Within a few years we had expanded into a larger space, added a second therapist, and begun specializing in the areas that mattered most to our community: sports rehab for local athletes, pre- and post-surgical protocols that improved outcomes, and neurological therapy for patients recovering from stroke, Parkinsons, and other conditions.</p>
<p>Today our team of licensed physical therapists brings together more than 75 combined years of experience across orthopedic, sports, post-surgical, and neurological rehabilitation. We remain committed to staying at the forefront of our field—regularly pursuing advanced certifications, attending conferences, and integrating the latest evidence-based techniques and technologies. Yet our core philosophy has never changed: we treat the whole person, not just the injury, and we measure our success by how quickly and completely you can reclaim the activities and independence that matter to you.</p>
<p>Whether youre an athlete returning to competition, a patient preparing for or recovering from surgery, or someone learning to navigate life with a neurological condition, were here to walk that journey with you—one thoughtful session at a time.</p>
</div>
</div>
</section>
<section class="bg-pt-blue-100 py-16 px-6">
<div class="rpt-container">
<h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight mb-8 text-center">What Sets Us Apart</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="rpt-card-rounded">
<h3 class="rpt-heading-md">One-on-one care</h3>
<p class="text-gray-600">You work with the same therapist throughout your treatment. No hand-offs, no assembly line.</p>
</div>
<div class="rpt-card-rounded">
<h3 class="rpt-heading-md">Root cause focus</h3>
<p class="text-gray-600">We don't just chase symptoms. We find and treat the underlying movement problems.</p>
</div>
<div class="rpt-card-rounded">
<h3 class="rpt-heading-md">Real results</h3>
<p class="text-gray-600">Our patients consistently report faster recovery times and lasting improvements.</p>
</div>
</div>
</div>
</section>
<section class="rpt-section">
<div class="max-w-[700px] mx-auto text-center">
<h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight mb-6">Ready to start your recovery?</h2>
<a href="/book-appointment" class="inline-block px-[4em] py-[1em] bg-pt-blue-500 text-white text-sm font-medium no-underline transition-colors border-2 border-pt-blue-500 hover:bg-pt-blue-600 hover:border-pt-blue-600">Make an Appointment</a>
</div>
</section>

View file

@ -0,0 +1,57 @@
<div class="rpt-container px-6 py-16 bg-pt-blue-50">
<h1 class="text-[clamp(2.5rem,5vw,4rem)] font-serif font-light text-gray-900 mb-4">Contact Us</h1>
<p class="text-xl text-gray-600 mb-6 max-w-2xl">
We'd love to hear from you. Reach out with questions about our services, insurance, or to schedule a visit.
</p>
<p class="text-xl text-gray-600 mb-12 max-w-2xl">
Note: This is not an actual healthcare practice. It is merely a demo of
<a href="https://coldairnetworks.com" class="rpt-link">Cold Air Networks'</a> website design services.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-12">
<div>
<h2 class="rpt-heading-lg mb-6">Our Location</h2>
<div class="text-gray-700 space-y-1 text-[15px] leading-relaxed">
<p><strong>Riverside Physical Therapy</strong></p>
<p>123 Riverside Drive, Suite 200</p>
<p>Riverside, CA 92501</p>
</div>
<h2 class="rpt-heading-lg mt-10 mb-6">Get in Touch</h2>
<div class="space-y-2 text-[15px]">
<p><strong>Phone:</strong> <a href="tel:9515550123" class="rpt-link">(951) 555-0123</a></p>
<p><strong>Email:</strong> <a href="mailto:info@coldairnetworks.com" class="rpt-link">info@coldairnetworks.com</a></p>
</div>
<h2 class="rpt-heading-lg mt-10 mb-6">Office Hours</h2>
<div class="text-gray-700 text-[15px] leading-relaxed">
<p>Monday Friday: 7:00 AM 7:00 PM</p>
<p>Saturday: 8:00 AM 12:00 PM</p>
<p>Sunday: Closed</p>
</div>
</div>
<div>
<h2 class="rpt-heading-lg mb-6">Send Us a Message</h2>
<p class="text-gray-600 mb-6 text-[15px]">
For appointment requests, please use our online booking tool — it's the fastest way to get scheduled.
</p>
<div class="bg-pt-blue-50 border border-pt-blue-200 p-8 rounded-2xl">
<p class="mb-4">Ready to get started?</p>
<a href="/book-appointment"
class="inline-block w-full text-center px-6 py-3 bg-pt-blue-500 text-white text-sm font-medium no-underline rounded-xl hover:bg-pt-blue-600 transition-colors">
Make an Appointment
</a>
<p class="text-xs text-gray-500 mt-3 text-center">
Or call us at (951) 555-0123 during business hours.
</p>
</div>
<p class="mt-8 text-sm text-gray-500">
We typically respond to emails within 1 business day. For urgent matters, please call.
</p>
</div>
</div>
</div>

View file

@ -1,16 +1,29 @@
<header class="rpt-header relative md:fixed md:top-0 md:left-0 md:right-0 md:z-50 p-5 bg-white shadow-sm" role="banner"> <header class="rpt-header relative md:fixed md:top-0 md:left-0 md:right-0 md:z-50 p-5 bg-white shadow-sm" role="banner">
<div class="flex flex-wrap items-center md:h-full max-w-[1200px] mx-auto px-6 gap-y-0 gap-x-8 md:gap-8"> <div class="flex flex-wrap items-center md:h-full max-w-[1200px] mx-auto px-6 gap-y-0 gap-x-8 md:gap-8">
<a class="text-lg font-bold text-[#1e3a5f] no-underline whitespace-nowrap shrink-0 hover:text-[#1e3a8a]" <a class="text-lg font-bold text-pt-blue-500 no-underline whitespace-nowrap shrink-0 hover:text-[pt-blue-600]"
href="{{ home_url }}">{{ site_name }}</a> href="{{ home_url }}">{{ site_name }}</a>
<nav class="flex-1 min-w-0" id="rpt-main-nav" aria-label="Main navigation"> <nav class="flex-1 min-w-0" id="rpt-main-nav" aria-label="Main navigation">
<ul class="flex items-center list-none m-0 p-0 gap-1"> <ul class="flex items-center list-none my-3 md:my-0 md:m-0 p-0 gap-1">
{% for item in menu_items %} {% for item in menu_items %}
{% if not item.is_cta %} {% if not item.is_cta %}
<li> <li>
<a class="block text-[15px] text-gray-700 no-underline px-3.5 py-1 hover:text-[#1e3a8a]{% if current_path == item.url %} text-[#1e3a8a] font-medium{% endif %}" <a class="
href="{{ item.url }}">{{ item.title }}</a> block text-[15px] text-gray-700 no-underline md:px-3.5
py-3
md:py-1
hover:text-[blue-900]
hover:bg-gray-100
md:hover:bg-transparent
{% if current_path == item.url %}
text-[blue-900] font-medium
{% endif %}
"
href="{{ item.url }}"
{% if item.title == 'Services' %}data-scroll-to="#pt-services"{% endif %}
{% if item.title == 'FAQ' %}data-scroll-to="#pt-faq"{% endif %}
>{{ item.title }}</a>
</li> </li>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -21,12 +34,21 @@
{% endfor %} {% endfor %}
{% if has_cta %} {% if has_cta %}
<li class="ml-auto px-6"> <li class="ml-auto px-6">
<div class="flex items-center gap-4">
{% for item in menu_items %} {% for item in menu_items %}
{% if item.is_cta %} {% if item.is_cta %}
<a class="inline-block px-5 py-2 rounded-none bg-[#306f8e] text-white text-sm font-medium no-underline rounded border border-[#1e3a5f] whitespace-nowrap transition-colors hover:bg-[#152a45] hover:border-[#152a45] hover:text-white" {% if item.title == 'Contact' %}
<a class="inline-block px-5 py-2 rounded-none bg-white text-pt-blue-500 text-sm font-medium no-underline rounded border-2 border-pt-blue-500 whitespace-nowrap transition-colors hover:bg-pt-blue-50 hover:text-[pt-blue-600]"
href="{{ item.url }}">{{ item.title }}</a> href="{{ item.url }}">{{ item.title }}</a>
{% else %}
<a class="inline-block px-5 py-2 rounded-none bg-pt-blue-500 text-white text-sm font-medium no-underline rounded border border-pt-navy whitespace-nowrap transition-colors hover:bg-pt-blue-600 hover:border-pt-blue-600 hover:text-white"
href="{{ item.url }}"
{% if item.title == 'Book An Appointment' %}data-scroll-to="#book-an-appointment"{% endif %}
>{{ item.title }}</a>
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
@ -36,9 +58,9 @@
aria-expanded="false" aria-expanded="false"
aria-controls="rpt-main-nav" aria-controls="rpt-main-nav"
aria-label="Toggle navigation"> aria-label="Toggle navigation">
<span class="block h-1 bg-[#1e3a5f] rounded-[3px] w-[45%] transition-transform duration-[250ms] ease-in-out"></span> <span class="block h-1 bg-pt-navy rounded-[3px] w-[45%] transition-transform duration-[250ms] ease-in-out"></span>
<span class="block h-1 bg-[#1e3a5f] rounded-[3px] w-full transition-[transform,opacity] duration-200 ease-in-out"></span> <span class="block h-1 bg-pt-navy rounded-[3px] w-full transition-[transform,opacity] duration-200 ease-in-out"></span>
<span class="block h-1 bg-[#1e3a5f] rounded-[3px] w-[45%] self-end transition-transform duration-[250ms] ease-in-out"></span> <span class="block h-1 bg-pt-navy rounded-[3px] w-[45%] self-end transition-transform duration-[250ms] ease-in-out"></span>
</button> </button>
</div> </div>

View file

@ -1,104 +1,107 @@
<section class=" <div class="bg-gradient-to-b from-pt-sage-400 to-pt-blue-400 pt-px">
relative bg-[#89a0a0] md:bg-[red] lg:bg-[blue] min-h-[560px] <section class="
relative md:mt-[78px] min-h-[480px]
2xl:flex
/* This number, 78px is supposed to match the header height (only when header is fixed) */
">
/* This number, 78px is supposed to match the header height */ {# Box 1: Image — full-bleed on mobile, offset on sm+ ; 2xl: left 62%, no padding, no offset #}
pt-[78px] <div class="absolute inset-0 flex sm:pt-8 sm:pb-8 2xl:static 2xl:inset-auto 2xl:w-[62%] 2xl:flex-none 2xl:p-0">
"> <div class="hidden sm:block sm:basis-[8%] sm:grow-[2] 2xl:hidden"></div>
{# Box 1: Image — full-bleed on mobile, offset on sm+ #}
<div class="absolute inset-0 flex">
<div class="hidden sm:block sm:basis-[8%] sm:grow-[2]"></div>
<img <img
src="/modules/custom/riverside_pt/images/hero.jpg" src="/modules/custom/riverside_pt/images/hero.jpg"
alt="A man helps a woman in a wheelchair" alt="A man helps a woman in a wheelchair"
class="basis-full sm:basis-[58%] sm:grow min-w-0 self-center object-cover object-[center_0%] max-h-[calc(100%_-_100px)]" /> class="basis-full sm:basis-[58%] sm:grow 2xl:basis-full 2xl:flex-none min-w-0 h-full object-cover object-[center_0%]" />
<div class="hidden sm:block sm:basis-[34%] sm:grow-[2]"></div> <div class="hidden sm:block sm:basis-[34%] sm:grow-[2] 2xl:hidden"></div>
</div> </div>
{# Gradient overlay on mobile so text stays readable over the image #} {# Gradient overlay on mobile so text stays readable over the image #}
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent sm:hidden"></div> <div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/30 to-transparent sm:hidden"></div>
{# Box 2: Text — spacer 50% | text 40% | spacer 10% #} {# Box 2: Text — spacer 50% | text 40% | spacer 10% ; 2xl: solid teal panel, text padded to align with nav CTA #}
<div class="relative flex min-h-[560px] py-8"> <div class="relative flex min-h-[480px] pt-0 pb-4 2xl:flex-1 2xl:bg-pt-blue-400 2xl:min-h-0 2xl:pb-0">
<div class="hidden sm:block sm:basis-[50%] sm:grow-[2]"></div> <div class="hidden sm:block sm:basis-[50%] sm:grow-[2] 2xl:hidden"></div>
<div class="basis-full sm:basis-[40%] sm:grow flex flex-col justify-end sm:justify-center px-6 sm:px-0 sm:pb-0 gap-[1vw]"> <div class="basis-full sm:basis-[40%] sm:grow flex flex-col justify-end sm:justify-center px-6 sm:px-0 sm:pb-0 gap-[1vw] 2xl:basis-full 2xl:grow-0 2xl:pl-16 2xl:pr-12 2xl:justify-center">
<h1 <h1
class="mt-0 mb-0 text-[clamp(1.5rem,3.5vw,3.25rem)] font-serif font-normal text-white leading-tight [text-shadow:-56.21px_2.55px_10.22px_#0000001A]" class="mt-0 mb-[1vw] text-[clamp(1.5rem,3.5vw,3.25rem)] font-serif font-normal text-white leading-none [text-shadow:-56.21px_2.55px_10.22px_rgb(0_0_0/10%)]"
> >
Restore your strength.<br>Reclaim your life. Restore your strength.<br>Reclaim your life.
</h1> </h1>
<p class="text-white/80 leading-tight text-[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"> <div class="flex gap-4 flex-wrap items-center mt-[2vw]">
<a <a
href="/schedule" href="/book-appointment"
class="w-full sm:w-auto text-center text-[1vw] px-[4em] py-[1em] bg-[#306f8e] text-white font-medium no-underline transition-colors hover:bg-[#3a6a7a]" data-scroll-to="#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> >Book An Appointment</a>
<a <a
href="/services" href="/services"
class="hidden sm:inline-block text-[1vw] px-[4em] py-[1em] border border-white/60 text-white font-medium no-underline transition-colors hover:bg-white/10" data-scroll-to="#pt-services"
class="hidden sm:inline-block text-[clamp(0.25rem,1vw,1.25vw)] px-[4em] py-[1em] bg-pt-blue-400 text-white font-medium no-underline transition-colors border-2 border-white hover:bg-pt-sage-500"
>View Our Services</a> >View Our Services</a>
</div> </div>
</div> </div>
<div class="hidden sm:block sm:basis-[10%] sm:grow-[2]"></div> <div class="hidden sm:block sm:basis-[10%] sm:grow-[2] 2xl:hidden"></div>
</div> </div>
</section> </section>
</div>
<section class="py-16 px-6 bg-white"> <section id="pt-services" class="rpt-section">
<div class="max-w-[1040px] mx-auto mb-12"> <div class="rpt-container mb-12">
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold text-center mb-3">Bringing Relief</p> <p class="text-sm tracking-widest uppercase text-pt-blue-500 font-semibold text-center mb-2">Bringing Relief</p>
<h2 class="text-[2.75rem] font-serif font-normal text-gray-900 leading-tight">Our Wide Range of Physical Therapy Services</h2> <h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight text-center">Our Wide Range of Physical Therapy Services</h2>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 max-w-[1040px] mx-auto"> <div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 rpt-container">
<div class="flex flex-col border border-[#b8d4dc] bg-white overflow-hidden"> <div class="rpt-card">
<img src="/modules/custom/riverside_pt/images/panels/1.jpg" alt="Diagnostic assessment" class="w-full h-48 object-cover" /> <img src="/modules/custom/riverside_pt/images/panels/1.jpg" alt="Diagnostic assessment" class="rpt-card__img" />
<div class="flex flex-col gap-4 p-6 flex-1"> <div class="rpt-card__body">
<h3 class="text-2xl font-normal text-gray-900">Diagnostic Assessment</h3> <h3 class="rpt-heading-lg">Diagnostic Assessment</h3>
<p class="text-[15px] text-gray-600 leading-relaxed flex-1">Your recovery starts with clarity. We perform a thorough evaluation of your condition, movement, and goals to create a precise, personalized treatment plan from day one.</p> <p class="rpt-body-text flex-1">Your recovery starts with clarity. We perform a thorough evaluation of your condition, movement, and goals to create a precise, personalized treatment plan from day one.</p>
<a href="/services" class="inline-flex items-center gap-3 px-5 py-3 bg-[#2d5f7a] text-white text-[15px] font-medium no-underline hover:bg-[#1e4a60]">More Info &rarr;</a> <a href="/services/diagnostic-assessment" class="rpt-btn">More Info &rarr;</a>
</div> </div>
</div> </div>
<div class="flex flex-col border border-[#b8d4dc] bg-white overflow-hidden"> <div class="rpt-card">
<img src="/modules/custom/riverside_pt/images/panels/2.jpg" alt="Sports rehabilitation" class="w-full h-48 object-cover" /> <img src="/modules/custom/riverside_pt/images/panels/2.jpg?v=2" alt="Sports rehabilitation" class="rpt-card__img" />
<div class="flex flex-col gap-4 p-6 flex-1"> <div class="rpt-card__body">
<h3 class="text-2xl font-normal text-gray-900">Sports Rehabilitation</h3> <h3 class="rpt-heading-lg">Sports Rehabilitation</h3>
<p class="text-[15px] text-gray-600 leading-relaxed flex-1">We help athletes recover from injury and return to peak performance with targeted, sport-specific programs built around your body and your goals.</p> <p class="rpt-body-text flex-1">We help athletes recover from injury and return to peak performance with targeted, sport-specific programs built around your body and your goals.</p>
<a href="/services" class="inline-flex items-center gap-3 px-5 py-3 bg-[#2d5f7a] text-white text-[15px] font-medium no-underline hover:bg-[#1e4a60]">More Info &rarr;</a> <a href="/services/sports-rehabilitation" class="rpt-btn">More Info &rarr;</a>
</div> </div>
</div> </div>
<div class="flex flex-col border border-[#b8d4dc] bg-white overflow-hidden"> <div class="rpt-card">
<img src="/modules/custom/riverside_pt/images/panels/3.jpg" alt="Pre and post-surgical rehab" class="w-full h-48 object-cover" /> <img src="/modules/custom/riverside_pt/images/panels/3.jpg" alt="Pre and post-surgical rehab" class="rpt-card__img" />
<div class="flex flex-col gap-4 p-6 flex-1"> <div class="rpt-card__body">
<h3 class="text-2xl font-normal text-gray-900">Pre/Post-Surgical Rehab</h3> <h3 class="rpt-heading-lg">Pre/Post-Surgical Rehab</h3>
<p class="text-[15px] text-gray-600 leading-relaxed flex-1">Expert care before and after surgery to reduce recovery time, minimize complications, and restore full strength and function.</p> <p class="rpt-body-text flex-1">Expert care before and after surgery to reduce recovery time, minimize complications, and restore full strength and function.</p>
<a href="/services" class="inline-flex items-center gap-3 px-5 py-3 bg-[#2d5f7a] text-white text-[15px] font-medium no-underline hover:bg-[#1e4a60]">More Info &rarr;</a> <a href="/services/pre-post-surgical-rehab" class="rpt-btn">More Info &rarr;</a>
</div> </div>
</div> </div>
<div class="flex flex-col border border-[#b8d4dc] bg-white overflow-hidden"> <div class="rpt-card">
<img src="/modules/custom/riverside_pt/images/panels/4.jpg" alt="Neurological physical therapy" class="w-full h-48 object-cover" /> <img src="/modules/custom/riverside_pt/images/panels/4.jpg" alt="Neurological physical therapy" class="rpt-card__img" />
<div class="flex flex-col gap-4 p-6 flex-1"> <div class="rpt-card__body">
<h3 class="text-2xl font-normal text-gray-900">Neurological Therapy</h3> <h3 class="rpt-heading-lg">Neurological Therapy</h3>
<p class="text-[15px] text-gray-600 leading-relaxed flex-1">Specialized therapy for nervous system conditions — helping you rebuild strength, coordination, and independence at every stage of recovery.</p> <p class="rpt-body-text flex-1">Specialized therapy for nervous system conditions — helping you rebuild strength, coordination, and independence at every stage of recovery.</p>
<a href="/services" class="inline-flex items-center gap-3 px-5 py-3 bg-[#2d5f7a] text-white text-[15px] font-medium no-underline hover:bg-[#1e4a60]">More Info &rarr;</a> <a href="/services/neurological-therapy" class="rpt-btn">More Info &rarr;</a>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="bg-[#dde8f0] py-20 px-6 overflow-hidden"> <section class="bg-pt-blue-100 py-20 px-6 overflow-hidden">
<div class="max-w-[1200px] mx-auto flex flex-col md:flex-row gap-12 md:gap-20 items-center"> <div class="max-w-[1200px] mx-auto flex flex-col md:flex-row gap-12 md:gap-20 items-center">
<div class="flex-1 flex flex-col gap-8 min-w-0"> <div class="flex-1 flex flex-col gap-8 min-w-0">
<h2 class="text-[clamp(2.5rem,4vw,3.75rem)] font-serif font-normal text-gray-900 leading-[1.1]">Our mission is to help you reclaim your body.</h2> <h2 class="text-[clamp(2.75rem,4.5vw,4rem)] font-serif font-light text-gray-900 leading-[1.1]">Our mission is to help you reclaim your body.</h2>
<p class="text-base text-gray-600 leading-relaxed max-w-lg">Every new patient begins with a comprehensive diagnostic assessment. From there, we create a fully personalized treatment plan tailored to your goals — whether that means returning to sport, recovering from surgery, or restoring neurological function.</p> <p class="text-base text-gray-700 font-light leading-relaxed max-w-lg">Every new patient begins with a comprehensive diagnostic assessment. From there, we create a fully personalized treatment plan tailored to your goals — whether that means returning to sport, recovering from surgery, or restoring neurological function.</p>
<div class="flex gap-12 pt-4"> <div class="flex gap-12 pt-4">
<div> <div class="font-hedvig text-center">
<p class="text-[4.5rem] font-serif text-[#306f8e] leading-none">15</p> <p class="text-[4.5rem] font-light text-pt-blue-500 leading-none">15</p>
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mt-2">Years Open</p> <p class="rpt-eyebrow mt-2">Years Open</p>
</div> </div>
<div> <div class="font-hedvig text-center">
<p class="text-[4.5rem] font-serif text-[#306f8e] leading-none">300</p> <p class="text-[4.5rem] text-pt-blue-500 leading-none">3,100</p>
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mt-2">Patients Served</p> <p class="rpt-eyebrow mt-2">Patients Served</p>
</div> </div>
</div> </div>
</div> </div>
@ -110,26 +113,17 @@
</div> </div>
</section> </section>
<section class="py-16 bg-white"> <section id="pt-testimonials" class="bg-white border-b border-pt-blue-300">
<div class="max-w-[1040px] mx-auto px-6 mb-8"> <rpt-testimonials class="block"></rpt-testimonials>
<h2 class="text-3xl font-bold text-blue-900 mb-1.5">Our Facility</h2>
<p class="text-[17px] text-gray-500">A look inside our clinic</p>
</div>
<rpt-carousel class="block"></rpt-carousel>
</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-[680px] mx-auto"> <div class="max-w-[700px] mx-auto">
<h2 class="text-[clamp(2.5rem,5vw,4rem)] font-serif font-normal text-gray-800 mb-10">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>
<fieldset class="border border-gray-300 rounded-lg px-4 pb-4 pt-0"> <rpt-booking class="block"></rpt-booking>
<legend class="text-sm text-gray-400 px-2">Appointment Type</legend>
<select class="w-full text-gray-700 text-base bg-transparent outline-none py-2 cursor-pointer">
<option value="">— Select A Service —</option>
<option value="diagnostic">Diagnostic Assessment</option>
<option value="sports">Sports Rehabilitation</option>
<option value="surgical">Pre/Post-Surgical Rehab</option>
<option value="neuro">Neurological Therapy</option>
</select>
</fieldset>
</div> </div>
</section> </section>
<rpt-faq id="pt-faq" class="block"></rpt-faq>
<div class="bg-pt-blue-400 h-[240px]"></div>

View file

@ -0,0 +1,42 @@
<div class="rpt-container px-6 py-12 bg-pt-blue-50">
<div class="mb-8">
<a href="/book-appointment" class="inline-flex items-center text-pt-blue-500 hover:text-pt-blue-600 text-sm font-medium">
← Make an Appointment
</a>
</div>
<h1 class="text-[2.5rem] font-serif font-normal text-gray-900 leading-tight mb-4">{{ title }}</h1>
<p class="text-xl text-gray-600 mb-10">{{ description }}</p>
<div class="max-w-none mb-12 text-gray-700 text-[15px] leading-relaxed space-y-4">
{{ long_description|raw }}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-12">
<div>
<h2 class="text-2xl font-normal mb-4">What to Expect</h2>
<div class="text-gray-700 text-[15px] leading-relaxed">
{{ what_to_expect|raw }}
</div>
</div>
<div>
<h2 class="text-2xl font-normal mb-4">Key Benefits</h2>
<ul class="space-y-3 text-gray-700">
{% for benefit in benefits %}
<li class="flex items-start gap-3">
<span class="mt-1.5 block w-2 h-2 bg-pt-blue-500 rounded-full flex-shrink-0"></span>
<span>{{ benefit }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
<div class="pt-8 border-t border-pt-blue-200 text-center">
<p class="mb-6 text-lg">Ready to get started with {{ title }}?</p>
<a href="/book-appointment" class="inline-flex items-center gap-3 px-8 py-4 bg-pt-blue-500 text-white text-[15px] font-medium no-underline hover:bg-pt-blue-600 rounded-xl">
Make an Appointment →
</a>
<p class="mt-3 text-sm text-gray-500">or call us to discuss your needs</p>
</div>
</div>

0
web/sites/default/files/.gitkeep Normal file → Executable file
View file

View file

@ -19,9 +19,24 @@ $settings['hash_salt'] = getenv('HASH_SALT') ?: 'replace-this-in-production';
$settings['update_free_access'] = FALSE; $settings['update_free_access'] = FALSE;
if ($postmark_key = getenv('POSTMARK_API_KEY')) { $is_dev = (bool) getenv('DEBUG');
$config['symfony_mailer.mailer_transport.postmark']['configuration']['dsn'] = $postmark_key = getenv('POSTMARK_API_KEY');
'postmark+api://' . $postmark_key . '@default';
if ($is_dev) {
$config['system.mail']['interface']['default'] = 'php_mail';
} else {
if (!$postmark_key) {
throw new \RuntimeException('POSTMARK_API_KEY is not set — refusing to start without a mail transport.');
}
$config['system.mail']['interface']['default'] = 'symfony_mailer';
$config['system.mail']['mailer_dsn'] = [
'scheme' => 'postmark+smtp',
'host' => 'default',
'user' => $postmark_key,
'password' => NULL,
'port' => NULL,
'options' => [],
];
} }
// Disable CSS/JS aggregation — assets served directly from source paths. // Disable CSS/JS aggregation — assets served directly from source paths.
@ -35,12 +50,19 @@ if (getenv('DEBUG')) {
$settings['cache']['bins']['page'] = 'cache.backend.null'; $settings['cache']['bins']['page'] = 'cache.backend.null';
} }
if ($base = getenv('BASE_URL')) { // Always allow localhost variants so missing/malformed BASE_URL never locks out local dev.
$base_url = $base; $settings['trusted_host_patterns'] = ['^localhost$', '^localhost:\d+$', '^127\.0\.0\.1$', '^127\.0\.0\.1:\d+$'];
}
if ($trusted = getenv('TRUSTED_HOST')) { if ($base = getenv('BASE_URL')) {
$settings['trusted_host_patterns'] = ['^' . preg_quote($trusted, '/') . '$']; // Ensure scheme is present so parse_url extracts host/port correctly.
} else { if (!preg_match('#^https?://#', $base)) {
$settings['trusted_host_patterns'] = ['^localhost$', '^127\.0\.0\.1$', '^0\.0\.0\.0$']; $base = 'http://' . $base;
}
$base_url = $base;
$parsed = parse_url($base);
$host = $parsed['host'] ?? '';
$port = isset($parsed['port']) ? ':' . $parsed['port'] : '';
if ($host && $host !== 'localhost' && !preg_match('/^127\./', $host)) {
$settings['trusted_host_patterns'][] = '^' . preg_quote($host . $port, '/') . '$';
}
} }