Compare commits

..

No commits in common. "cc2df201cd0ffc0d819445d084d427c6a55c4891" and "0a2e80f7b0d8953f2eb3bdf8e85cf1d124c02e82" have entirely different histories.

22 changed files with 188 additions and 725 deletions

View file

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

View file

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

View file

@ -10,14 +10,12 @@ 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: "Portfolio"
ADMIN_PASS: "${ADMIN_PASS:-admin}" ADMIN_PASS: "${ADMIN_PASS:-admin}"
HASH_SALT: "${HASH_SALT:-replace-this-in-production-with-a-long-random-string}" HASH_SALT: "${HASH_SALT:-replace-this-in-production-with-a-long-random-string}"
POSTMARK_API_KEY: "${POSTMARK_API_KEY:-}" POSTMARK_API_KEY: "${POSTMARK_API_KEY:-}"
BASE_URL: "${BASE_URL:-http://localhost:8080}"
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/modules/custom:/var/www/html/web/modules/custom
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy

View file

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

View file

@ -1,11 +0,0 @@
{
"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"
}
}

View file

@ -1,11 +0,0 @@
/** @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,10 +79,9 @@
} }
#riverside-calendar .riverside-holiday-label { #riverside-calendar .riverside-holiday-label {
overflow-wrap: break-word;
line-height: 1; line-height: 1;
font-size: 0.65rem; font-size: 0.65rem;
color: #b45309; color: #b45309;
text-align: center; text-align: center;
padding: 2px; padding-bottom: 2px;
} }

View file

@ -1,60 +0,0 @@
@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,30 +47,15 @@
panelSlots.innerHTML = ''; panelSlots.innerHTML = '';
arg.allSegs.forEach(function (seg) { arg.allSegs.forEach(function (seg) {
const li = document.createElement('li'); const li = document.createElement('li');
const startLabel = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); const start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const endLabel = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const a = document.createElement('a'); const params = new URLSearchParams({
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, start: seg.event.startStr,
end: seg.event.endStr, 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); li.appendChild(a);
panelSlots.appendChild(li); panelSlots.appendChild(li);
}); });

View file

@ -1,28 +0,0 @@
(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,46 +1,31 @@
<?php <?php
use Drupal\node\Entity\Node;
use Drupal\node\Entity\NodeType; 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\user\Entity\Role;
use Drupal\field\Entity\FieldStorageConfig; use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldConfig;
function riverside_pt_install() { function riverside_pt_install() {
// Pass 1: types, roles, and field storages (no inter-dependencies). array_walk([
// Appointment
NodeType::create([ NodeType::create([
'type' => 'appointment', 'type' => 'appointment',
'name' => 'Appointment', 'name' => 'Appointment',
'description' => 'A booking between a Patient and a Provider at a particular time.', 'description' => 'A booking between a Patient and a Provider at a particular time.',
'new_revision' => FALSE, 'new_revision' => FALSE,
'display_submitted' => 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([ FieldStorageConfig::create([
'field_name' => 'field_appointment_date', 'field_name' => 'field_appointment_date',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'datetime', 'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'], 'settings' => ['datetime_type' => 'datetime'],
])->save(); ]),
FieldStorageConfig::create([ FieldStorageConfig::create([
'field_name' => 'field_duration_minutes', 'field_name' => 'field_duration_minutes',
'entity_type' => 'node', 'entity_type' => 'node',
'type' => 'integer', 'type' => 'integer',
])->save(); ]),
FieldStorageConfig::create([ FieldStorageConfig::create([
'field_name' => 'field_service_type', 'field_name' => 'field_service_type',
'entity_type' => 'node', 'entity_type' => 'node',
@ -53,57 +38,34 @@ function riverside_pt_install() {
'neurological_pt' => 'Neurological PT', 'neurological_pt' => 'Neurological PT',
], ],
], ],
])->save(); ]),
FieldStorageConfig::create([ FieldStorageConfig::create([
'field_name' => 'field_provider', '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(); ]),
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([ FieldConfig::create([
'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(); ]),
FieldConfig::create([ 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(); ]),
FieldConfig::create([ 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(); ]),
FieldConfig::create([ FieldConfig::create([
'field_name' => 'field_provider', 'field_name' => 'field_provider',
'entity_type' => 'node', 'entity_type' => 'node',
@ -113,11 +75,40 @@ function riverside_pt_install() {
'settings' => [ 'settings' => [
'handler' => 'default:user', 'handler' => 'default:user',
'handler_settings' => [ '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([ FieldConfig::create([
'field_name' => 'field_provider', 'field_name' => 'field_provider',
'entity_type' => 'node', 'entity_type' => 'node',
@ -127,77 +118,26 @@ function riverside_pt_install() {
'settings' => [ 'settings' => [
'handler' => 'default:user', 'handler' => 'default:user',
'handler_settings' => [ 'handler_settings' => [
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']], 'filter' => [
'type' => '_role',
'role' => ['provider' => 'provider'],
], ],
], ],
])->save(); ],
]),
FieldConfig::create([ 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(); ]),
FieldConfig::create([ 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(); ]),
], fn($entity) => $entity->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,10 +1,3 @@
app:
css:
theme:
css/app.css: {}
js:
js/nav.js: {}
schedule: schedule:
css: css:
theme: theme:

View file

@ -1,79 +1,5 @@
<?php <?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 { function riverside_pt_mail(string $key, array &$message, array $params): void {
if ($key !== 'booking_request') { if ($key !== 'booking_request') {
return; return;
@ -83,15 +9,9 @@ function riverside_pt_mail(string $key, array &$message, array $params): void {
$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 = [ $message['body'][] = implode("\n", [
'Name: ' . $params['first_name'] . ' ' . $params['last_name'], 'Name: ' . $params['first_name'] . ' ' . $params['last_name'],
'Phone: ' . $params['phone'], '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'),
]; ]);
if (!empty($params['comments'])) {
$lines[] = 'Comments: ' . $params['comments'];
}
$message['body'][] = implode("\n", $lines);
} }

View file

@ -1,11 +1,3 @@
riverside_pt.home:
path: '/home'
defaults:
_controller: '\Drupal\riverside_pt\Controller\HomeController::page'
_title: 'Riverside Physical Therapy'
requirements:
_permission: 'access content'
riverside_pt.schedule: riverside_pt.schedule:
path: '/schedule' path: '/schedule'
defaults: defaults:
@ -22,14 +14,6 @@ riverside_pt.booking:
requirements: requirements:
_permission: 'access content' _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: riverside_pt.schedule_events:
path: '/schedule/events' path: '/schedule/events'
defaults: defaults:

View file

@ -1,13 +0,0 @@
<?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,25 +3,12 @@
namespace Drupal\riverside_pt\Controller; namespace Drupal\riverside_pt\Controller;
use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url; use Drupal\Core\Url;
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 {
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 { public function page(): array {
return [ return [
'#type' => 'container', '#type' => 'container',
@ -75,7 +62,6 @@ class ScheduleController extends ControllerBase {
'riversidePt' => [ 'riversidePt' => [
'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(), 'eventsUrl' => Url::fromRoute('riverside_pt.schedule_events')->toString(),
'bookingUrl' => Url::fromRoute('riverside_pt.booking')->toString(), 'bookingUrl' => Url::fromRoute('riverside_pt.booking')->toString(),
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
'holidays' => $this->buildHolidaysMap(), 'holidays' => $this->buildHolidaysMap(),
], ],
], ],
@ -92,23 +78,6 @@ class ScheduleController extends ControllerBase {
return $map; 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 { 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');

View file

@ -6,29 +6,26 @@ use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Mail\MailManagerInterface; use Drupal\Core\Mail\MailManagerInterface;
use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\user\Entity\User; use Drupal\user\Entity\User;
use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class BookingForm extends FormBase { class BookingForm extends FormBase {
private PrivateTempStore $tempStore;
public function __construct( public function __construct(
private readonly MailManagerInterface $mailManager, private readonly MailManagerInterface $mailManager,
ConfigFactoryInterface $configFactory, ConfigFactoryInterface $configFactory,
PrivateTempStoreFactory $tempStoreFactory, RequestStack $requestStack,
) { ) {
$this->configFactory = $configFactory; $this->configFactory = $configFactory;
$this->tempStore = $tempStoreFactory->get('riverside_pt'); $this->requestStack = $requestStack;
} }
public static function create(ContainerInterface $container): static { public static function create(ContainerInterface $container): static {
return new static( return new static(
$container->get('plugin.manager.mail'), $container->get('plugin.manager.mail'),
$container->get('config.factory'), $container->get('config.factory'),
$container->get('tempstore.private'), $container->get('request_stack'),
); );
} }
@ -37,10 +34,10 @@ class BookingForm extends FormBase {
} }
public function buildForm(array $form, FormStateInterface $form_state): array { public function buildForm(array $form, FormStateInterface $form_state): array {
$slot = $this->tempStore->get('booking_slot') ?? []; $query = $this->requestStack->getCurrentRequest()->query;
$start = $slot['start'] ?? ''; $start = $query->get('start', '');
$end = $slot['end'] ?? ''; $end = $query->get('end', '');
$uid = $slot['provider_id'] ?? ''; $uid = $query->get('provider', '');
$slot_display = ''; $slot_display = '';
if ($start && $end) { if ($start && $end) {
@ -49,8 +46,6 @@ class BookingForm extends FormBase {
$slot_display = $s->format('l, F j, Y') . ', ' . $s->format('g:i A') . '' . $e->format('g:i A'); $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'] = [ $form['slot_summary'] = [
'#type' => 'item', '#type' => 'item',
'#title' => $this->t('Appointment'), '#title' => $this->t('Appointment'),
@ -65,6 +60,10 @@ 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'] = [ $form['first_name'] = [
'#type' => 'textfield', '#type' => 'textfield',
'#title' => $this->t('First name'), '#title' => $this->t('First name'),
@ -83,12 +82,6 @@ class BookingForm extends FormBase {
'#required' => TRUE, '#required' => TRUE,
]; ];
$form['comments'] = [
'#type' => 'textarea',
'#title' => $this->t('Comments'),
'#rows' => 4,
];
$form['actions'] = ['#type' => 'actions']; $form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = [ $form['actions']['submit'] = [
'#type' => 'submit', '#type' => 'submit',
@ -98,49 +91,19 @@ class BookingForm extends FormBase {
return $form; 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 { 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'); $to = $this->configFactory->get('riverside_pt.settings')->get('notification_email');
$lang = $this->languageManager()->getDefaultLanguage()->getId(); $lang = $this->languageManager()->getDefaultLanguage()->getId();
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [ $params = [
'first_name' => $form_state->getValue('first_name'), 'first_name' => $form_state->getValue('first_name'),
'last_name' => $form_state->getValue('last_name'), 'last_name' => $form_state->getValue('last_name'),
'phone' => $form_state->getValue('phone'), 'phone' => $form_state->getValue('phone'),
'comments' => $form_state->getValue('comments'), 'start' => $form_state->getValue('start'),
'start' => $slot['start'] ?? '', 'end' => $form_state->getValue('end'),
'end' => $slot['end'] ?? '', ];
]);
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, $params);
if ($sent['result']) { if ($sent['result']) {
$this->messenger()->addStatus($this->t('Your request has been submitted. We will contact you to confirm.')); $this->messenger()->addStatus($this->t('Your request has been submitted. We will contact you to confirm.'));

View file

@ -1,45 +0,0 @@
<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

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