Compare commits

..

10 commits

Author SHA1 Message Date
Mork Swork
cc2df201cd Mobile responsiveness
Some checks failed
Build and push image / build (push) Failing after 22s
2026-05-16 12:14:44 -07:00
Mork Swork
a1f1b5902e Tailwind 2026-05-16 11:45:33 -07:00
Mork Swork
ee265b679d Switch themes 2026-05-16 09:52:21 -07:00
Mork Swork
4649c56a58 Hamburger menu 2026-05-14 21:23:54 -07:00
Mork Swork
4ab06a69b5 Try to hide search 2026-05-14 20:52:42 -07:00
Mork Swork
f4d7c724cb Tweak top bar 2026-05-14 20:39:30 -07:00
Mork Swork
ed6ff4fbf6 Add front page, nav rebuild, and CI workflow
- Home controller with hero and services sections
- Nav rebuilt on install: Home, Services, About, FAQ, Contact (CTA), Book An Appointment (CTA)
- Entrypoint uses IS_SETUP check so failed installs retry on restart
- Gitea Actions workflow builds and pushes multi-arch image to forge.quinefoundation.com

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:05:46 -07:00
Mork Swork
d9e6d852d9 fix build script 2026-05-14 15:07:02 -07:00
Mork Swork
7cc733a03e Style fixes 2026-05-13 21:14:57 -07:00
Mork Swork
9454dc03b4 wip flow 2026-05-13 15:26:33 -07:00
22 changed files with 725 additions and 188 deletions

View file

@ -0,0 +1,33 @@
name: Build and push image
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: forge.quinefoundation.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: forge.quinefoundation.com/ironmagma/riverside:latest

View file

@ -12,6 +12,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
unzip \
locales \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg && \
@ -33,7 +36,9 @@ WORKDIR /var/www/html
# To use ../drupal instead, add it as a path repository in composer.json:
# "repositories": [{"type": "path", "url": "../drupal/core", "options": {"symlink": false}}]
# then bump drupal/core-recommended to "11.x-dev@dev" and rebuild.
COPY composer.json ./
COPY composer.json package.json tailwind.config.js ./
RUN npm install --include=dev
RUN composer config repositories.drupal composer https://packages.drupal.org/8
@ -59,6 +64,8 @@ COPY web/sites/default/settings.php web/sites/default/settings.php
COPY web/sites/default/files/ web/sites/default/files/
COPY web/modules/custom/ web/modules/custom/
RUN npm run build
ARG FULLCALENDAR_VERSION=6.1.15
RUN curl -fsSL "https://cdn.jsdelivr.net/npm/fullcalendar@${FULLCALENDAR_VERSION}/index.global.min.js" \
-o web/modules/custom/riverside_pt/js/fullcalendar.min.js

1
build.sh Normal file
View file

@ -0,0 +1 @@
docker buildx build --platform linux/amd64,linux/arm64 -t forge.quinefoundation.com/ironmagma/riverside:latest --push .

View file

@ -10,12 +10,14 @@ services:
DB_NAME: drupal
DB_USER: drupal
DB_PASS: drupal
SITE_NAME: "Portfolio"
SITE_NAME: "Riverside Therapeutics"
ADMIN_PASS: "${ADMIN_PASS:-admin}"
HASH_SALT: "${HASH_SALT:-replace-this-in-production-with-a-long-random-string}"
POSTMARK_API_KEY: "${POSTMARK_API_KEY:-}"
BASE_URL: "${BASE_URL:-http://localhost:8080}"
volumes:
- ./web/sites/default/files:/var/www/html/web/sites/default/files
- ./web/modules/custom:/var/www/html/web/modules/custom
depends_on:
postgres:
condition: service_healthy

View file

@ -1,5 +1,4 @@
#!/bin/sh
set -e
DB_HOST="${DB_HOST:-postgres}"
DB_USER="${DB_USER:-drupal}"
@ -16,42 +15,59 @@ cd /var/www/html
DRUSH="vendor/bin/drush --root=/var/www/html/web"
HAS_TABLES=$($DRUSH sql:query \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='config';" \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" \
2>/dev/null || echo "0")
if [ "$HAS_TABLES" = "1" ]; then
echo "[entrypoint] Database populated, importing configuration..."
$DRUSH config:import -y 2>/dev/null && \
echo "[entrypoint] Config imported." || \
echo "[entrypoint] No config to import, continuing."
$DRUSH theme:enable claro_compact -y
else
IS_SETUP=$($DRUSH sql:query \
"SELECT COUNT(*) FROM config WHERE name='core.extension' AND data LIKE '%riverside_pt%';" \
2>/dev/null || echo "0")
if [ "$HAS_TABLES" != "1" ]; then
echo "[entrypoint] Fresh database, installing Drupal..."
$DRUSH site:install standard \
--site-name="${SITE_NAME:-Portfolio}" \
--account-name=admin \
--account-pass="${ADMIN_PASS:-admin}" \
-y
-y || { echo "[entrypoint] FATAL: site:install failed."; exit 1; }
echo "[entrypoint] Drupal installed."
fi
echo "[entrypoint] Enabling modules..."
$DRUSH en -y views views_ui field_ui text options link datetime
$DRUSH en -y webform webform_ui
$DRUSH en -y symfony_mailer
if [ "$IS_SETUP" != "1" ]; then
echo "[entrypoint] Running setup (first boot or recovery from failed setup)..."
$DRUSH en -y riverside_pt
echo "[entrypoint] Modules enabled."
$DRUSH en -y views views_ui field_ui text options link datetime && \
echo "[entrypoint] Core modules enabled." || echo "[entrypoint] WARNING: core modules failed."
$DRUSH en -y webform webform_ui && \
echo "[entrypoint] Webform enabled." || echo "[entrypoint] WARNING: webform failed."
$DRUSH en -y symfony_mailer && \
echo "[entrypoint] Mailer enabled." || echo "[entrypoint] WARNING: symfony_mailer failed."
$DRUSH en -y riverside_pt && \
echo "[entrypoint] riverside_pt enabled." || echo "[entrypoint] WARNING: riverside_pt failed."
echo "[entrypoint] Setting themes..."
$DRUSH theme:enable olivero claro_compact
echo "[entrypoint] Themes set."
$DRUSH config:set system.site page.front /home -y && \
echo "[entrypoint] Front page set." || echo "[entrypoint] WARNING: front page config failed."
$DRUSH theme:enable starterkit_theme claro_compact -y && \
$DRUSH config:set system.theme default starterkit_theme -y && \
$DRUSH config:set system.theme admin claro_compact -y && \
echo "[entrypoint] Themes set." || echo "[entrypoint] WARNING: theme enable failed."
if ls /var/www/html/config/sync/*.yml >/dev/null 2>&1; then
echo "[entrypoint] Importing configuration from sync dir..."
$DRUSH config:import -y
$DRUSH config:import --partial -y || echo "[entrypoint] WARNING: config import failed."
fi
echo "[entrypoint] Setup complete."
else
echo "[entrypoint] Setup already complete, importing configuration..."
$DRUSH config:import -y >/dev/null 2>&1 && \
echo "[entrypoint] Config imported." || \
echo "[entrypoint] No config to import, continuing."
fi
npm run build --prefix /var/www/html >/dev/null 2>&1 && echo "[entrypoint] Tailwind built." || echo "[entrypoint] WARNING: Tailwind build failed."
$DRUSH cache:rebuild >/dev/null 2>&1 && echo "[entrypoint] Cache rebuilt."
echo "[entrypoint] Starting services..."
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf

11
package.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "riverside-therapeutics",
"private": true,
"scripts": {
"watch": "tailwindcss -i ./web/modules/custom/riverside_pt/css/tailwind.css -o ./web/modules/custom/riverside_pt/css/app.css --watch",
"build": "tailwindcss -i ./web/modules/custom/riverside_pt/css/tailwind.css -o ./web/modules/custom/riverside_pt/css/app.css --minify"
},
"devDependencies": {
"tailwindcss": "^3.4.17"
}
}

11
tailwind.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./web/modules/custom/riverside_pt/templates/**/*.twig',
'./web/modules/custom/riverside_pt/src/**/*.php',
],
theme: {
extend: {},
},
plugins: [],
}

File diff suppressed because one or more lines are too long

View file

@ -79,9 +79,10 @@
}
#riverside-calendar .riverside-holiday-label {
overflow-wrap: break-word;
line-height: 1;
font-size: 0.65rem;
color: #b45309;
text-align: center;
padding-bottom: 2px;
padding: 2px;
}

View file

@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
padding-top: 80px;
}
/* Neutralise any theme container constraints */
.page-wrapper {
max-width: none;
padding-inline: 0;
}
/* Hide Olivero/theme chrome we don't use */
.site-header,
.sticky-header-toggle,
.social-bar {
display: none !important;
}
}
@layer components {
/* Mobile nav: max-height slide can't be expressed with utilities alone */
@media (max-width: 767px) {
#rpt-main-nav {
flex: none;
position: absolute;
top: 5rem;
left: 0;
right: 0;
background: #fff;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.3s ease, opacity 0.25s ease;
}
#rpt-main-nav.is-open {
max-height: 500px;
opacity: 1;
}
#rpt-main-nav ul {
flex-direction: column;
align-items: stretch;
}
}
/* Hamburger open-state animation */
.rpt-header__hamburger[aria-expanded="true"] span:nth-child(1) {
width: 100% !important;
transform: translateY(10px) rotate(45deg);
}
.rpt-header__hamburger[aria-expanded="true"] span:nth-child(2) {
opacity: 0;
}
.rpt-header__hamburger[aria-expanded="true"] span:nth-child(3) {
width: 100% !important;
transform: translateY(-10px) rotate(-45deg);
}
}

View file

@ -47,15 +47,30 @@
panelSlots.innerHTML = '';
arg.allSegs.forEach(function (seg) {
const li = document.createElement('li');
const start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const params = new URLSearchParams({
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';
}
});
});
const a = document.createElement('a');
a.href = drupalSettings.riversidePt.bookingUrl + '?' + params.toString();
a.textContent = start + ' ' + end;
li.appendChild(a);
panelSlots.appendChild(li);
});

View file

@ -0,0 +1,28 @@
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', function () {
var btn = document.querySelector('.rpt-header__hamburger');
var nav = document.getElementById('rpt-main-nav');
if (!btn || !nav) return;
btn.addEventListener('click', function () {
var open = nav.classList.toggle('is-open');
btn.setAttribute('aria-expanded', String(open));
});
nav.querySelectorAll('a').forEach(function (link) {
link.addEventListener('click', function () {
nav.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false');
});
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.rpt-header')) {
nav.classList.remove('is-open');
btn.setAttribute('aria-expanded', 'false');
}
});
});
})();

View file

@ -1,31 +1,46 @@
<?php
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType;
use Drupal\menu_link_content\Entity\MenuLinkContent;
use Drupal\path_alias\Entity\PathAlias;
use Drupal\user\Entity\Role;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig;
function riverside_pt_install() {
array_walk([
// Appointment
// Pass 1: types, roles, and field storages (no inter-dependencies).
NodeType::create([
'type' => 'appointment',
'name' => 'Appointment',
'description' => 'A booking between a Patient and a Provider at a particular time.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
]),
])->save();
NodeType::create([
'type' => 'provider_availability',
'name' => 'Provider Availability',
'description' => 'A window of time during which a Provider is available for appointments.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
Role::create(['id' => 'provider', 'label' => 'Provider'])->save();
FieldStorageConfig::create([
'field_name' => 'field_appointment_date',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
]),
])->save();
FieldStorageConfig::create([
'field_name' => 'field_duration_minutes',
'entity_type' => 'node',
'type' => 'integer',
]),
])->save();
FieldStorageConfig::create([
'field_name' => 'field_service_type',
'entity_type' => 'node',
@ -38,34 +53,57 @@ function riverside_pt_install() {
'neurological_pt' => 'Neurological PT',
],
],
]),
])->save();
FieldStorageConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node',
'type' => 'entity_reference',
'settings' => ['target_type' => 'user'],
]),
])->save();
FieldStorageConfig::create([
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
FieldStorageConfig::create([
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
// Clear field definition cache so FieldConfig::preSave() can find the storages.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Pass 2: field configs (depend on storages being in the DB).
FieldConfig::create([
'field_name' => 'field_appointment_date',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Appointment Date',
'required' => TRUE,
]),
])->save();
FieldConfig::create([
'field_name' => 'field_duration_minutes',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Duration (Minutes)',
'required' => TRUE,
]),
])->save();
FieldConfig::create([
'field_name' => 'field_service_type',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Service Type',
'required' => TRUE,
]),
])->save();
FieldConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node',
@ -75,40 +113,11 @@ function riverside_pt_install() {
'settings' => [
'handler' => 'default:user',
'handler_settings' => [
'filter' => [
'type' => '_role',
'role' => ['provider' => 'provider'],
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
],
],
],
]),
])->save();
// Provider
Role::create([
'id' => 'provider',
'label' => 'Provider',
]),
// Provider Availability
NodeType::create([
'type' => 'provider_availability',
'name' => 'Provider Availability',
'description' => 'A window of time during which a Provider is available for appointments.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
]),
FieldStorageConfig::create([
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
]),
FieldStorageConfig::create([
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
]),
FieldConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node',
@ -118,26 +127,77 @@ function riverside_pt_install() {
'settings' => [
'handler' => 'default:user',
'handler_settings' => [
'filter' => [
'type' => '_role',
'role' => ['provider' => 'provider'],
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
],
],
],
]),
])->save();
FieldConfig::create([
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'Start',
'required' => TRUE,
]),
])->save();
FieldConfig::create([
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'End',
'required' => TRUE,
]),
], fn($entity) => $entity->save());
])->save();
try {
_riverside_pt_build_navigation();
}
catch (\Exception $e) {
\Drupal::logger('riverside_pt')->error('Navigation setup failed: @msg', ['@msg' => $e->getMessage()]);
}
\Drupal::configFactory()->getEditable('system.site')
->set('page.front', '/home')
->save();
}
function _riverside_pt_build_navigation(): void {
$em = \Drupal::entityTypeManager();
foreach ($em->getStorage('menu_link_content')->loadByProperties(['menu_name' => 'main']) as $link) {
$link->delete();
}
foreach (['Services', 'About', 'FAQ', 'Contact'] as $title) {
if ($em->getStorage('node')->loadByProperties(['title' => $title, 'type' => 'page'])) {
continue;
}
$node = Node::create(['type' => 'page', 'title' => $title, 'status' => 1]);
$node->save();
PathAlias::create([
'path' => '/node/' . $node->id(),
'alias' => '/' . strtolower($title),
'langcode' => 'en',
])->save();
}
$defs = [
['title' => 'Home', 'uri' => 'route:<front>', 'weight' => 0, 'class' => NULL],
['title' => 'Services', 'uri' => 'internal:/services', 'weight' => 1, 'class' => NULL],
['title' => 'About', 'uri' => 'internal:/about', 'weight' => 2, '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' => 'Book An Appointment', 'uri' => 'internal:/schedule', 'weight' => 5, 'class' => 'nav-cta nav-cta--primary'],
];
foreach ($defs as $def) {
$options = $def['class'] ? ['attributes' => ['class' => explode(' ', $def['class'])]] : [];
MenuLinkContent::create([
'title' => $def['title'],
'link' => ['uri' => $def['uri'], 'options' => $options],
'menu_name' => 'main',
'weight' => $def['weight'],
'enabled' => TRUE,
])->save();
}
}

View file

@ -1,3 +1,10 @@
app:
css:
theme:
css/app.css: {}
js:
js/nav.js: {}
schedule:
css:
theme:

View file

@ -1,5 +1,79 @@
<?php
use Drupal\Core\Breadcrumb\Breadcrumb;
use Drupal\Core\Link;
use Drupal\Core\Routing\RouteMatchInterface;
function riverside_pt_page_attachments(array &$attachments): void {
$attachments['#attached']['library'][] = 'riverside_pt/app';
}
function riverside_pt_theme(): array {
return [
'riverside_pt_header' => [
'variables' => [
'site_name' => NULL,
'home_url' => NULL,
'menu_items' => [],
'current_path' => NULL,
],
],
'riverside_pt_home' => [
'variables' => [],
],
];
}
function riverside_pt_page_top(array &$page_top): void {
$page_top['rpt_header'] = [
'#theme' => 'riverside_pt_header',
'#cache' => ['contexts' => ['url.path']],
];
}
function riverside_pt_preprocess_riverside_pt_header(array &$variables): void {
$variables['site_name'] = \Drupal::config('system.site')->get('name');
$variables['home_url'] = \Drupal\Core\Url::fromRoute('<front>')->toString();
$tree_service = \Drupal::service('menu.link_tree');
$params = new \Drupal\Core\Menu\MenuTreeParameters();
$params->setMaxDepth(1);
$tree = $tree_service->load('main', $params);
$tree = $tree_service->transform($tree, [
['callable' => 'menu.default_tree_manipulators:checkAccess'],
['callable' => 'menu.default_tree_manipulators:generateIndexAndSort'],
]);
$seen = [];
$items = [];
foreach ($tree as $element) {
if (!$element->access->isAllowed()) {
continue;
}
$url = $element->link->getUrlObject()->toString();
if (in_array($url, $seen, TRUE)) {
continue;
}
$seen[] = $url;
$title = (string) $element->link->getTitle();
$items[] = [
'title' => $title,
'url' => $url,
'is_cta' => ($title === 'Book An Appointment' || $title === 'Contact'),
];
}
$variables['menu_items'] = $items;
$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 {
if ($key !== 'booking_request') {
return;
@ -9,9 +83,15 @@ function riverside_pt_mail(string $key, array &$message, array $params): void {
$end = new \DateTime($params['end']);
$message['subject'] = 'Booking request — ' . $start->format('M j, Y g:i A');
$message['body'][] = implode("\n", [
$lines = [
'Name: ' . $params['first_name'] . ' ' . $params['last_name'],
'Phone: ' . $params['phone'],
'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '' . $end->format('g:i A'),
]);
];
if (!empty($params['comments'])) {
$lines[] = 'Comments: ' . $params['comments'];
}
$message['body'][] = implode("\n", $lines);
}

View file

@ -1,3 +1,11 @@
riverside_pt.home:
path: '/home'
defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::page'
_title: 'Riverside Physical Therapy'
requirements:
_permission: 'access content'
riverside_pt.schedule:
path: '/schedule'
defaults:
@ -14,6 +22,14 @@ riverside_pt.booking:
requirements:
_permission: 'access content'
riverside_pt.booking_store_slot:
path: '/schedule/book/slot'
defaults:
_controller: '\Drupal\riverside_pt\Controller\ScheduleController::storeSlot'
requirements:
_permission: 'access content'
methods: [POST]
riverside_pt.schedule_events:
path: '/schedule/events'
defaults:

View file

@ -0,0 +1,13 @@
<?php
namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase;
class HomeController extends ControllerBase {
public function page(): array {
return ['#theme' => 'riverside_pt_home'];
}
}

View file

@ -3,12 +3,25 @@
namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class ScheduleController extends ControllerBase {
private PrivateTempStore $tempStore;
public function __construct(PrivateTempStoreFactory $tempStoreFactory) {
$this->tempStore = $tempStoreFactory->get('riverside_pt');
}
public static function create(ContainerInterface $container): static {
return new static($container->get('tempstore.private'));
}
public function page(): array {
return [
'#type' => 'container',
@ -62,6 +75,7 @@ class ScheduleController extends ControllerBase {
'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(),
],
],
@ -78,6 +92,23 @@ class ScheduleController extends ControllerBase {
return $map;
}
public function storeSlot(Request $request): JsonResponse {
$data = json_decode($request->getContent(), TRUE) ?? [];
$start = $data['start'] ?? '';
if (!$start || new \DateTime($start) < new \DateTime()) {
return new JsonResponse(['error' => 'past'], 422);
}
$this->tempStore->set('booking_slot', [
'start' => $start,
'end' => $data['end'] ?? '',
'provider_id' => $data['provider_id'] ?? '',
]);
return new JsonResponse(['ok' => TRUE]);
}
public function events(Request $request): JsonResponse {
$start = $request->query->get('start');
$end = $request->query->get('end');

View file

@ -6,26 +6,29 @@ use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\user\Entity\User;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class BookingForm extends FormBase {
private PrivateTempStore $tempStore;
public function __construct(
private readonly MailManagerInterface $mailManager,
ConfigFactoryInterface $configFactory,
RequestStack $requestStack,
PrivateTempStoreFactory $tempStoreFactory,
) {
$this->configFactory = $configFactory;
$this->requestStack = $requestStack;
$this->tempStore = $tempStoreFactory->get('riverside_pt');
}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('plugin.manager.mail'),
$container->get('config.factory'),
$container->get('request_stack'),
$container->get('tempstore.private'),
);
}
@ -34,10 +37,10 @@ class BookingForm extends FormBase {
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$query = $this->requestStack->getCurrentRequest()->query;
$start = $query->get('start', '');
$end = $query->get('end', '');
$uid = $query->get('provider', '');
$slot = $this->tempStore->get('booking_slot') ?? [];
$start = $slot['start'] ?? '';
$end = $slot['end'] ?? '';
$uid = $slot['provider_id'] ?? '';
$slot_display = '';
if ($start && $end) {
@ -46,6 +49,8 @@ class BookingForm extends FormBase {
$slot_display = $s->format('l, F j, Y') . ', ' . $s->format('g:i A') . '' . $e->format('g:i A');
}
$form['#cache'] = ['max-age' => 0];
$form['slot_summary'] = [
'#type' => 'item',
'#title' => $this->t('Appointment'),
@ -60,10 +65,6 @@ class BookingForm extends FormBase {
];
}
$form['start'] = ['#type' => 'hidden', '#value' => $start];
$form['end'] = ['#type' => 'hidden', '#value' => $end];
$form['provider_id'] = ['#type' => 'hidden', '#value' => $uid];
$form['first_name'] = [
'#type' => 'textfield',
'#title' => $this->t('First name'),
@ -82,6 +83,12 @@ class BookingForm extends FormBase {
'#required' => TRUE,
];
$form['comments'] = [
'#type' => 'textarea',
'#title' => $this->t('Comments'),
'#rows' => 4,
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [
'#type' => 'submit',
@ -91,19 +98,49 @@ class BookingForm extends FormBase {
return $form;
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
$slot = $this->tempStore->get('booking_slot') ?? [];
$start = $slot['start'] ?? '';
if (!$start) {
$form_state->setError($form['slot_summary'], $this->t('No slot selected. Please go back and choose a time.'));
return;
}
if (new \DateTime($start) < new \DateTime()) {
$form_state->setError($form['slot_summary'], $this->t('That slot is in the past. Please go back and choose another time.'));
return;
}
$provider_id = $slot['provider_id'] ?? '';
$conflict = \Drupal::entityQuery('node')
->condition('type', 'appointment')
->condition('field_appointment_date', $start)
->condition('field_provider', $provider_id ?: 0)
->accessCheck(FALSE)
->count()
->execute();
if ($conflict > 0) {
$form_state->setError($form['slot_summary'], $this->t('That slot was just booked. Please go back and choose another time.'));
}
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
$slot = $this->tempStore->get('booking_slot') ?? [];
$this->tempStore->delete('booking_slot');
$to = $this->configFactory->get('riverside_pt.settings')->get('notification_email');
$lang = $this->languageManager()->getDefaultLanguage()->getId();
$params = [
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [
'first_name' => $form_state->getValue('first_name'),
'last_name' => $form_state->getValue('last_name'),
'phone' => $form_state->getValue('phone'),
'start' => $form_state->getValue('start'),
'end' => $form_state->getValue('end'),
];
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, $params);
'comments' => $form_state->getValue('comments'),
'start' => $slot['start'] ?? '',
'end' => $slot['end'] ?? '',
]);
if ($sent['result']) {
$this->messenger()->addStatus($this->t('Your request has been submitted. We will contact you to confirm.'));

View file

@ -0,0 +1,45 @@
<header class="rpt-header fixed top-0 left-0 right-0 z-50 h-20 bg-white shadow-sm" role="banner">
<div class="flex items-center h-full max-w-[1200px] mx-auto px-6 gap-8">
<a class="text-lg font-bold text-[#1e3a5f] no-underline whitespace-nowrap shrink-0 hover:text-[#1e3a8a]"
href="{{ home_url }}">{{ site_name }}</a>
<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">
{% for item in menu_items %}
{% if not item.is_cta %}
<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 %}"
href="{{ item.url }}">{{ item.title }}</a>
</li>
{% endif %}
{% endfor %}
{% set has_cta = false %}
{% for item in menu_items %}
{% if item.is_cta %}{% set has_cta = true %}{% endif %}
{% endfor %}
{% if has_cta %}
<li class="ml-auto px-6">
{% for item in menu_items %}
{% if item.is_cta %}
<a class="inline-block px-5 py-2 bg-[#1e3a5f] 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"
href="{{ item.url }}">{{ item.title }}</a>
{% endif %}
{% endfor %}
</li>
{% endif %}
</ul>
</nav>
<button class="rpt-header__hamburger flex md:hidden flex-col justify-center gap-1.5 w-11 h-11 p-2 bg-transparent border-0 cursor-pointer shrink-0 ml-auto"
aria-expanded="false"
aria-controls="rpt-main-nav"
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-[#1e3a5f] 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>
</button>
</div>
</header>

View file

@ -0,0 +1,58 @@
<section class="relative bg-[#aac6c6] min-h-[560px]">
{# 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>
<div class="basis-full sm:basis-[58%] sm:grow self-stretch">
<img src="/modules/custom/riverside_pt/images/hero.jpg" alt="" class="w-full h-full object-cover object-center" />
</div>
<div class="hidden sm:block sm:basis-[34%] sm:grow-[2]"></div>
</div>
{# 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>
{# Box 2: Text — spacer 50% | text 38% | spacer 12% #}
<div class="relative flex min-h-[560px] py-8">
<div class="hidden sm:block sm:basis-[50%] sm:grow-[2]"></div>
<div class="basis-full sm:basis-[38%] sm:grow flex flex-col justify-end sm:justify-center px-6 sm:px-0 pb-10 sm:pb-0 py-16 gap-6">
<h1 class="text-[clamp(2rem,3.5vw,3.25rem)] font-serif font-normal text-white leading-tight">Restore your strength.<br>Reclaim your life.</h1>
<p class="text-white/80 text-lg leading-relaxed">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">
<a href="/schedule" class="w-full sm:w-auto text-center px-8 py-3 bg-[#4a7a8a] text-white text-sm font-medium no-underline transition-colors hover:bg-[#3a6a7a]">Book An Appointment</a>
<a href="/services" class="hidden sm:inline-block px-8 py-3 border border-white/60 text-white text-sm font-medium no-underline transition-colors hover:bg-white/10">View Our Services</a>
</div>
</div>
<div class="hidden sm:block sm:basis-[12%] sm:grow-[2]"></div>
</div>
</section>
<section class="py-16 px-6 bg-gray-100">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-blue-900 mb-1.5">Bringing Relief</h2>
<p class="text-[17px] text-gray-500">Our wide range of services</p>
</div>
<div class="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-6 max-w-[1040px] mx-auto">
<div class="flex flex-col gap-3 border border-gray-200 rounded-lg p-6 bg-white">
<h3 class="text-[17px] font-semibold text-gray-900">Diagnostic Evaluation</h3>
<p class="text-[15px] text-gray-500 leading-relaxed flex-1">Comprehensive assessment to identify the root cause of your pain and build a personalized recovery plan.</p>
<a href="/services" class="inline-block px-4 py-2 text-[14px] text-blue-500 border border-blue-500 rounded no-underline transition-colors hover:bg-blue-500 hover:text-white">More Info</a>
</div>
<div class="flex flex-col gap-3 border border-gray-200 rounded-lg p-6 bg-white">
<h3 class="text-[17px] font-semibold text-gray-900">Sports Rehabilitation</h3>
<p class="text-[15px] text-gray-500 leading-relaxed flex-1">Targeted programs to help athletes recover from injury and return to peak performance safely.</p>
<a href="/services" class="inline-block px-4 py-2 text-[14px] text-blue-500 border border-blue-500 rounded no-underline transition-colors hover:bg-blue-500 hover:text-white">More Info</a>
</div>
<div class="flex flex-col gap-3 border border-gray-200 rounded-lg p-6 bg-white">
<h3 class="text-[17px] font-semibold text-gray-900">Pre/Post-Surgical Rehab</h3>
<p class="text-[15px] text-gray-500 leading-relaxed flex-1">Expert care before and after surgery to maximize recovery outcomes and restore full function.</p>
<a href="/services" class="inline-block px-4 py-2 text-[14px] text-blue-500 border border-blue-500 rounded no-underline transition-colors hover:bg-blue-500 hover:text-white">More Info</a>
</div>
<div class="flex flex-col gap-3 border border-gray-200 rounded-lg p-6 bg-white">
<h3 class="text-[17px] font-semibold text-gray-900">Neurological Physical Therapy</h3>
<p class="text-[15px] text-gray-500 leading-relaxed flex-1">Specialized therapy for nervous system conditions, helping you regain strength and independence.</p>
<a href="/services" class="inline-block px-4 py-2 text-[14px] text-blue-500 border border-blue-500 rounded no-underline transition-colors hover:bg-blue-500 hover:text-white">More Info</a>
</div>
</div>
</section>

View file

@ -28,6 +28,10 @@ if ($postmark_key = getenv('POSTMARK_API_KEY')) {
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;
if ($base = getenv('BASE_URL')) {
$base_url = $base;
}
if ($trusted = getenv('TRUSTED_HOST')) {
$settings['trusted_host_patterns'] = ['^' . preg_quote($trusted, '/') . '$'];
} else {