Compare commits
10 commits
0a2e80f7b0
...
cc2df201cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc2df201cd | ||
|
|
a1f1b5902e | ||
|
|
ee265b679d | ||
|
|
4649c56a58 | ||
|
|
4ab06a69b5 | ||
|
|
f4d7c724cb | ||
|
|
ed6ff4fbf6 | ||
|
|
d9e6d852d9 | ||
|
|
7cc733a03e | ||
|
|
9454dc03b4 |
22 changed files with 725 additions and 188 deletions
33
.gitea/workflows/build.yml
Normal file
33
.gitea/workflows/build.yml
Normal 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
|
||||||
|
|
@ -12,6 +12,9 @@ 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 && \
|
||||||
|
|
@ -33,7 +36,9 @@ 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 ./
|
COPY composer.json package.json tailwind.config.js ./
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -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/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
|
||||||
|
|
|
||||||
1
build.sh
Normal file
1
build.sh
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 -t forge.quinefoundation.com/ironmagma/riverside:latest --push .
|
||||||
|
|
@ -10,12 +10,14 @@ services:
|
||||||
DB_NAME: drupal
|
DB_NAME: drupal
|
||||||
DB_USER: drupal
|
DB_USER: drupal
|
||||||
DB_PASS: drupal
|
DB_PASS: drupal
|
||||||
SITE_NAME: "Portfolio"
|
SITE_NAME: "Riverside Therapeutics"
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
#!/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}"
|
||||||
|
|
@ -16,42 +15,59 @@ 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='config';" \
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" \
|
||||||
2>/dev/null || echo "0")
|
2>/dev/null || echo "0")
|
||||||
|
|
||||||
if [ "$HAS_TABLES" = "1" ]; then
|
IS_SETUP=$($DRUSH sql:query \
|
||||||
echo "[entrypoint] Database populated, importing configuration..."
|
"SELECT COUNT(*) FROM config WHERE name='core.extension' AND data LIKE '%riverside_pt%';" \
|
||||||
$DRUSH config:import -y 2>/dev/null && \
|
2>/dev/null || echo "0")
|
||||||
echo "[entrypoint] Config imported." || \
|
|
||||||
echo "[entrypoint] No config to import, continuing."
|
if [ "$HAS_TABLES" != "1" ]; then
|
||||||
$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
|
-y || { echo "[entrypoint] FATAL: site:install failed."; exit 1; }
|
||||||
echo "[entrypoint] Drupal installed."
|
echo "[entrypoint] Drupal installed."
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[entrypoint] Enabling modules..."
|
if [ "$IS_SETUP" != "1" ]; then
|
||||||
$DRUSH en -y views views_ui field_ui text options link datetime
|
echo "[entrypoint] Running setup (first boot or recovery from failed setup)..."
|
||||||
$DRUSH en -y webform webform_ui
|
|
||||||
$DRUSH en -y symfony_mailer
|
|
||||||
|
|
||||||
$DRUSH en -y riverside_pt
|
$DRUSH en -y views views_ui field_ui text options link datetime && \
|
||||||
echo "[entrypoint] Modules enabled."
|
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 config:set system.site page.front /home -y && \
|
||||||
$DRUSH theme:enable olivero claro_compact
|
echo "[entrypoint] Front page set." || echo "[entrypoint] WARNING: front page config failed."
|
||||||
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 -y
|
$DRUSH config:import --partial -y || echo "[entrypoint] WARNING: config import failed."
|
||||||
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
|
||||||
|
|
|
||||||
11
package.json
Normal file
11
package.json
Normal 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
11
tailwind.config.js
Normal 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: [],
|
||||||
|
}
|
||||||
1
web/modules/custom/riverside_pt/css/app.css
Normal file
1
web/modules/custom/riverside_pt/css/app.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -79,9 +79,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#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-bottom: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
web/modules/custom/riverside_pt/css/tailwind.css
Normal file
60
web/modules/custom/riverside_pt/css/tailwind.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,15 +47,30 @@
|
||||||
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 start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
const startLabel = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
const endLabel = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
const params = new URLSearchParams({
|
|
||||||
start: seg.event.startStr,
|
|
||||||
end: seg.event.endStr,
|
|
||||||
});
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = drupalSettings.riversidePt.bookingUrl + '?' + params.toString();
|
a.href = '#';
|
||||||
a.textContent = start + ' – ' + end;
|
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);
|
li.appendChild(a);
|
||||||
panelSlots.appendChild(li);
|
panelSlots.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
28
web/modules/custom/riverside_pt/js/nav.js
Normal file
28
web/modules/custom/riverside_pt/js/nav.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
@ -1,143 +1,203 @@
|
||||||
<?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() {
|
||||||
array_walk([
|
// Pass 1: types, roles, and field storages (no inter-dependencies).
|
||||||
// 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();
|
||||||
]),
|
|
||||||
FieldStorageConfig::create([
|
|
||||||
'field_name' => 'field_appointment_date',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'type' => 'datetime',
|
|
||||||
'settings' => ['datetime_type' => 'datetime'],
|
|
||||||
]),
|
|
||||||
FieldStorageConfig::create([
|
|
||||||
'field_name' => 'field_duration_minutes',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'type' => 'integer',
|
|
||||||
]),
|
|
||||||
FieldStorageConfig::create([
|
|
||||||
'field_name' => 'field_service_type',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'type' => 'list_string',
|
|
||||||
'settings' => [
|
|
||||||
'allowed_values' => [
|
|
||||||
'diagnostic' => 'Diagnostic',
|
|
||||||
'sports_rehab' => 'Sports Rehab',
|
|
||||||
'pre_post_surgical_rehab' => 'Pre/Post-Surgical Rehab',
|
|
||||||
'neurological_pt' => 'Neurological PT',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
FieldStorageConfig::create([
|
|
||||||
'field_name' => 'field_provider',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'type' => 'entity_reference',
|
|
||||||
'settings' => ['target_type' => 'user'],
|
|
||||||
]),
|
|
||||||
FieldConfig::create([
|
|
||||||
'field_name' => 'field_appointment_date',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'bundle' => 'appointment',
|
|
||||||
'label' => 'Appointment Date',
|
|
||||||
'required' => TRUE,
|
|
||||||
]),
|
|
||||||
FieldConfig::create([
|
|
||||||
'field_name' => 'field_duration_minutes',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'bundle' => 'appointment',
|
|
||||||
'label' => 'Duration (Minutes)',
|
|
||||||
'required' => TRUE,
|
|
||||||
]),
|
|
||||||
FieldConfig::create([
|
|
||||||
'field_name' => 'field_service_type',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'bundle' => 'appointment',
|
|
||||||
'label' => 'Service Type',
|
|
||||||
'required' => TRUE,
|
|
||||||
]),
|
|
||||||
FieldConfig::create([
|
|
||||||
'field_name' => 'field_provider',
|
|
||||||
'entity_type' => 'node',
|
|
||||||
'bundle' => 'appointment',
|
|
||||||
'label' => 'Provider',
|
|
||||||
'required' => TRUE,
|
|
||||||
'settings' => [
|
|
||||||
'handler' => 'default:user',
|
|
||||||
'handler_settings' => [
|
|
||||||
'filter' => [
|
|
||||||
'type' => '_role',
|
|
||||||
'role' => ['provider' => 'provider'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Provider
|
NodeType::create([
|
||||||
Role::create([
|
'type' => 'provider_availability',
|
||||||
'id' => 'provider',
|
'name' => 'Provider Availability',
|
||||||
'label' => 'Provider',
|
'description' => 'A window of time during which a Provider is available for appointments.',
|
||||||
]),
|
'new_revision' => FALSE,
|
||||||
|
'display_submitted' => FALSE,
|
||||||
|
])->save();
|
||||||
|
|
||||||
// Provider Availability
|
Role::create(['id' => 'provider', 'label' => 'Provider'])->save();
|
||||||
NodeType::create([
|
|
||||||
'type' => 'provider_availability',
|
FieldStorageConfig::create([
|
||||||
'name' => 'Provider Availability',
|
'field_name' => 'field_appointment_date',
|
||||||
'description' => 'A window of time during which a Provider is available for appointments.',
|
'entity_type' => 'node',
|
||||||
'new_revision' => FALSE,
|
'type' => 'datetime',
|
||||||
'display_submitted' => FALSE,
|
'settings' => ['datetime_type' => 'datetime'],
|
||||||
]),
|
])->save();
|
||||||
FieldStorageConfig::create([
|
|
||||||
'field_name' => 'field_start_datetime',
|
FieldStorageConfig::create([
|
||||||
'entity_type' => 'node',
|
'field_name' => 'field_duration_minutes',
|
||||||
'type' => 'datetime',
|
'entity_type' => 'node',
|
||||||
'settings' => ['datetime_type' => 'datetime'],
|
'type' => 'integer',
|
||||||
]),
|
])->save();
|
||||||
FieldStorageConfig::create([
|
|
||||||
'field_name' => 'field_end_datetime',
|
FieldStorageConfig::create([
|
||||||
'entity_type' => 'node',
|
'field_name' => 'field_service_type',
|
||||||
'type' => 'datetime',
|
'entity_type' => 'node',
|
||||||
'settings' => ['datetime_type' => 'datetime'],
|
'type' => 'list_string',
|
||||||
]),
|
'settings' => [
|
||||||
FieldConfig::create([
|
'allowed_values' => [
|
||||||
'field_name' => 'field_provider',
|
'diagnostic' => 'Diagnostic',
|
||||||
'entity_type' => 'node',
|
'sports_rehab' => 'Sports Rehab',
|
||||||
'bundle' => 'provider_availability',
|
'pre_post_surgical_rehab' => 'Pre/Post-Surgical Rehab',
|
||||||
'label' => 'Provider',
|
'neurological_pt' => 'Neurological PT',
|
||||||
'required' => TRUE,
|
],
|
||||||
'settings' => [
|
],
|
||||||
'handler' => 'default:user',
|
])->save();
|
||||||
'handler_settings' => [
|
|
||||||
'filter' => [
|
FieldStorageConfig::create([
|
||||||
'type' => '_role',
|
'field_name' => 'field_provider',
|
||||||
'role' => ['provider' => 'provider'],
|
'entity_type' => 'node',
|
||||||
],
|
'type' => 'entity_reference',
|
||||||
],
|
'settings' => ['target_type' => 'user'],
|
||||||
],
|
])->save();
|
||||||
]),
|
|
||||||
FieldConfig::create([
|
FieldStorageConfig::create([
|
||||||
'field_name' => 'field_start_datetime',
|
'field_name' => 'field_start_datetime',
|
||||||
'entity_type' => 'node',
|
'entity_type' => 'node',
|
||||||
'bundle' => 'provider_availability',
|
'type' => 'datetime',
|
||||||
'label' => 'Start',
|
'settings' => ['datetime_type' => 'datetime'],
|
||||||
'required' => TRUE,
|
])->save();
|
||||||
]),
|
|
||||||
FieldConfig::create([
|
FieldStorageConfig::create([
|
||||||
'field_name' => 'field_end_datetime',
|
'field_name' => 'field_end_datetime',
|
||||||
'entity_type' => 'node',
|
'entity_type' => 'node',
|
||||||
'bundle' => 'provider_availability',
|
'type' => 'datetime',
|
||||||
'label' => 'End',
|
'settings' => ['datetime_type' => 'datetime'],
|
||||||
'required' => TRUE,
|
])->save();
|
||||||
]),
|
|
||||||
], fn($entity) => $entity->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',
|
||||||
|
'bundle' => 'appointment',
|
||||||
|
'label' => 'Provider',
|
||||||
|
'required' => TRUE,
|
||||||
|
'settings' => [
|
||||||
|
'handler' => 'default:user',
|
||||||
|
'handler_settings' => [
|
||||||
|
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
FieldConfig::create([
|
||||||
|
'field_name' => 'field_provider',
|
||||||
|
'entity_type' => 'node',
|
||||||
|
'bundle' => 'provider_availability',
|
||||||
|
'label' => 'Provider',
|
||||||
|
'required' => TRUE,
|
||||||
|
'settings' => [
|
||||||
|
'handler' => 'default:user',
|
||||||
|
'handler_settings' => [
|
||||||
|
'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,
|
||||||
|
])->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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
app:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
css/app.css: {}
|
||||||
|
js:
|
||||||
|
js/nav.js: {}
|
||||||
|
|
||||||
schedule:
|
schedule:
|
||||||
css:
|
css:
|
||||||
theme:
|
theme:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,79 @@
|
||||||
<?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;
|
||||||
|
|
@ -9,9 +83,15 @@ 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');
|
||||||
$message['body'][] = implode("\n", [
|
$lines = [
|
||||||
'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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
riverside_pt.schedule:
|
||||||
path: '/schedule'
|
path: '/schedule'
|
||||||
defaults:
|
defaults:
|
||||||
|
|
@ -14,6 +22,14 @@ 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:
|
||||||
|
|
|
||||||
|
|
@ -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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -3,12 +3,25 @@
|
||||||
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',
|
||||||
|
|
@ -60,9 +73,10 @@ class ScheduleController extends ControllerBase {
|
||||||
'library' => ['riverside_pt/schedule'],
|
'library' => ['riverside_pt/schedule'],
|
||||||
'drupalSettings' => [
|
'drupalSettings' => [
|
||||||
'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(),
|
||||||
'holidays' => $this->buildHolidaysMap(),
|
'storeSlotUrl' => Url::fromRoute('riverside_pt.booking_store_slot')->toString(),
|
||||||
|
'holidays' => $this->buildHolidaysMap(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -78,6 +92,23 @@ 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');
|
||||||
|
|
@ -98,10 +129,10 @@ class ScheduleController extends ControllerBase {
|
||||||
$slot = clone $current;
|
$slot = clone $current;
|
||||||
$slot->setTime(9 + $n, 0);
|
$slot->setTime(9 + $n, 0);
|
||||||
$events[] = [
|
$events[] = [
|
||||||
'id' => $id++,
|
'id' => $id++,
|
||||||
'title' => 'Available',
|
'title' => 'Available',
|
||||||
'start' => $slot->format('Y-m-d\TH:i:s'),
|
'start' => $slot->format('Y-m-d\TH:i:s'),
|
||||||
'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');
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,29 @@ 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,
|
||||||
RequestStack $requestStack,
|
PrivateTempStoreFactory $tempStoreFactory,
|
||||||
) {
|
) {
|
||||||
$this->configFactory = $configFactory;
|
$this->configFactory = $configFactory;
|
||||||
$this->requestStack = $requestStack;
|
$this->tempStore = $tempStoreFactory->get('riverside_pt');
|
||||||
}
|
}
|
||||||
|
|
||||||
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('request_stack'),
|
$container->get('tempstore.private'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,10 +37,10 @@ class BookingForm extends FormBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(array $form, FormStateInterface $form_state): array {
|
public function buildForm(array $form, FormStateInterface $form_state): array {
|
||||||
$query = $this->requestStack->getCurrentRequest()->query;
|
$slot = $this->tempStore->get('booking_slot') ?? [];
|
||||||
$start = $query->get('start', '');
|
$start = $slot['start'] ?? '';
|
||||||
$end = $query->get('end', '');
|
$end = $slot['end'] ?? '';
|
||||||
$uid = $query->get('provider', '');
|
$uid = $slot['provider_id'] ?? '';
|
||||||
|
|
||||||
$slot_display = '';
|
$slot_display = '';
|
||||||
if ($start && $end) {
|
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');
|
$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'),
|
||||||
|
|
@ -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'] = [
|
$form['first_name'] = [
|
||||||
'#type' => 'textfield',
|
'#type' => 'textfield',
|
||||||
'#title' => $this->t('First name'),
|
'#title' => $this->t('First name'),
|
||||||
|
|
@ -82,6 +83,12 @@ 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',
|
||||||
|
|
@ -91,19 +98,49 @@ 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();
|
||||||
|
|
||||||
$params = [
|
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, [
|
||||||
'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'),
|
||||||
'start' => $form_state->getValue('start'),
|
'comments' => $form_state->getValue('comments'),
|
||||||
'end' => $form_state->getValue('end'),
|
'start' => $slot['start'] ?? '',
|
||||||
];
|
'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.'));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -28,6 +28,10 @@ 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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue