wip booking flow
This commit is contained in:
parent
269813c12d
commit
0a2e80f7b0
14 changed files with 343 additions and 6 deletions
24
README.md
24
README.md
|
|
@ -17,6 +17,30 @@ make shell # open a bash shell in the app container
|
||||||
make drush <cmd> # run any drush command, e.g. make drush cr
|
make drush <cmd> # run any drush command, e.g. make drush cr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
### Seed provider availability
|
||||||
|
|
||||||
|
Populates `provider_availability` nodes for the next calendar month across all active providers, using randomised noise per provider.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make drush php-script scripts/seed_availability.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview without saving:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEED_DRY_RUN=1 make drush php-script scripts/seed_availability.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Wipe existing availability for the month before seeding:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEED_WIPE=1 make drush php-script scripts/seed_availability.php
|
||||||
|
```
|
||||||
|
|
||||||
|
Running the script twice without `SEED_WIPE=1` will create duplicates.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
- **FullCalendar View** — interactive appointment calendar
|
- **FullCalendar View** — interactive appointment calendar
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
"license": "GPL-2.0-or-later",
|
"license": "GPL-2.0-or-later",
|
||||||
"minimum-stability": "dev",
|
"minimum-stability": "dev",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
|
"require": {
|
||||||
|
"symfony/http-client": "^7.0",
|
||||||
|
"symfony/postmark-mailer": "^7.0"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"composer/installers": true,
|
"composer/installers": true,
|
||||||
|
|
|
||||||
8
config/sync/symfony_mailer.mailer_transport.postmark.yml
Normal file
8
config/sync/symfony_mailer.mailer_transport.postmark.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
langcode: en
|
||||||
|
status: true
|
||||||
|
dependencies: {}
|
||||||
|
id: postmark
|
||||||
|
label: Postmark
|
||||||
|
plugin: dsn
|
||||||
|
configuration:
|
||||||
|
dsn: 'postmark+api://change-me@default'
|
||||||
|
|
@ -13,6 +13,7 @@ services:
|
||||||
SITE_NAME: "Portfolio"
|
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:-}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./web/sites/default/files:/var/www/html/web/sites/default/files
|
- ./web/sites/default/files:/var/www/html/web/sites/default/files
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
|
||||||
133
scripts/seed_availability.php
Normal file
133
scripts/seed_availability.php
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed provider_availability nodes for the next calendar month.
|
||||||
|
*
|
||||||
|
* Usage: drush php-script scripts/seed_availability.php
|
||||||
|
*
|
||||||
|
* Options (env vars):
|
||||||
|
* SEED_DRY_RUN=1 Print what would be created without saving.
|
||||||
|
* SEED_WIPE=1 Delete existing availability for the period first.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$dryRun = (bool) getenv('SEED_DRY_RUN');
|
||||||
|
$wipe = (bool) getenv('SEED_WIPE');
|
||||||
|
|
||||||
|
// --- Date range: next full calendar month ---
|
||||||
|
$start = new DateTimeImmutable('first day of next month 00:00:00');
|
||||||
|
$end = new DateTimeImmutable('last day of next month 23:59:59');
|
||||||
|
|
||||||
|
// --- Load providers ---
|
||||||
|
$providerIds = \Drupal::entityQuery('user')
|
||||||
|
->condition('roles', 'provider')
|
||||||
|
->condition('status', 1)
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
if (empty($providerIds)) {
|
||||||
|
echo "No active users with the 'provider' role found. Aborting.\n";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$providers = \Drupal\user\Entity\User::loadMultiple($providerIds);
|
||||||
|
echo sprintf("Found %d provider(s): %s\n",
|
||||||
|
count($providers),
|
||||||
|
implode(', ', array_map(fn($u) => $u->getDisplayName(), $providers))
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Optionally wipe existing availability in the range ---
|
||||||
|
if ($wipe) {
|
||||||
|
$existing = \Drupal::entityQuery('node')
|
||||||
|
->condition('type', 'provider_availability')
|
||||||
|
->condition('field_start_datetime', $start->format('Y-m-d\TH:i:s'), '>=')
|
||||||
|
->condition('field_start_datetime', $end->format('Y-m-d\TH:i:s'), '<=')
|
||||||
|
->accessCheck(FALSE)
|
||||||
|
->execute();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
echo sprintf("Deleting %d existing availability node(s)...\n", count($existing));
|
||||||
|
if (!$dryRun) {
|
||||||
|
$storage = \Drupal::entityTypeManager()->getStorage('node');
|
||||||
|
$storage->delete($storage->loadMultiple($existing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slot generation config ---
|
||||||
|
// Working hours: 8am–4pm (slots are 1 hour, last slot starts at 4pm)
|
||||||
|
const SLOT_DURATION_MINUTES = 60;
|
||||||
|
const SLOT_START_HOUR = 8;
|
||||||
|
const SLOT_END_HOUR = 16;
|
||||||
|
const SLOT_HOURS = [8, 9, 10, 11, 13, 14, 15, 16]; // skip noon
|
||||||
|
|
||||||
|
// Per-provider noise: each provider gets an independent random pattern.
|
||||||
|
// We use a seeded approach so the same provider always generates the same
|
||||||
|
// schedule for a given run, but differs from other providers.
|
||||||
|
function providerSeed(\Drupal\user\Entity\User $user): int {
|
||||||
|
return crc32($user->getDisplayName() . $user->id());
|
||||||
|
}
|
||||||
|
|
||||||
|
function noisySlotCount(int $providerSeed, DateTimeImmutable $date): int {
|
||||||
|
// Combine provider seed with day-of-year for per-day variance.
|
||||||
|
$daySeed = $providerSeed ^ (int) $date->format('z') * 2654435761;
|
||||||
|
// Map to 0–5, weighted toward 1–3.
|
||||||
|
$raw = abs($daySeed) % 12;
|
||||||
|
return [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 4, 5][$raw];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate nodes ---
|
||||||
|
$created = 0;
|
||||||
|
$current = $start;
|
||||||
|
|
||||||
|
while ($current <= $end) {
|
||||||
|
foreach ($providers as $provider) {
|
||||||
|
$count = noisySlotCount(providerSeed($provider), $current);
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$current = $current->modify('+1 day');
|
||||||
|
continue 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick $count distinct hours from SLOT_HOURS without replacement.
|
||||||
|
$hours = SLOT_HOURS;
|
||||||
|
shuffle($hours);
|
||||||
|
$selectedHours = array_slice($hours, 0, $count);
|
||||||
|
sort($selectedHours);
|
||||||
|
|
||||||
|
foreach ($selectedHours as $hour) {
|
||||||
|
$slotStart = $current->setTime($hour, 0);
|
||||||
|
$slotEnd = $slotStart->modify('+' . SLOT_DURATION_MINUTES . ' minutes');
|
||||||
|
|
||||||
|
$label = sprintf('%s — %s',
|
||||||
|
$provider->getDisplayName(),
|
||||||
|
$slotStart->format('Y-m-d H:i')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
echo "[DRY RUN] Would create: $label\n";
|
||||||
|
} else {
|
||||||
|
\Drupal\node\Entity\Node::create([
|
||||||
|
'type' => 'provider_availability',
|
||||||
|
'title' => $label,
|
||||||
|
'status' => 1,
|
||||||
|
'uid' => 1,
|
||||||
|
'field_provider' => ['target_id' => $provider->id()],
|
||||||
|
'field_start_datetime' => $slotStart->format('Y-m-d\TH:i:s'),
|
||||||
|
'field_end_datetime' => $slotEnd->format('Y-m-d\TH:i:s'),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $current->modify('+1 day');
|
||||||
|
}
|
||||||
|
|
||||||
|
$verb = $dryRun ? 'Would create' : 'Created';
|
||||||
|
echo sprintf("%s %d availability slot(s) across %d provider(s) for %s.\n",
|
||||||
|
$verb,
|
||||||
|
$created,
|
||||||
|
count($providers),
|
||||||
|
$start->format('F Y')
|
||||||
|
);
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
notification_email: 'admin@example.com'
|
||||||
holidays:
|
holidays:
|
||||||
- date: '2026-01-01'
|
- date: '2026-01-01'
|
||||||
name: "New Year's Day"
|
name: "New Year's Day"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ riverside_pt.settings:
|
||||||
type: config_object
|
type: config_object
|
||||||
label: 'Riverside PT Settings'
|
label: 'Riverside PT Settings'
|
||||||
mapping:
|
mapping:
|
||||||
|
notification_email:
|
||||||
|
type: string
|
||||||
|
label: 'Notification email address'
|
||||||
holidays:
|
holidays:
|
||||||
type: sequence
|
type: sequence
|
||||||
label: 'Holidays'
|
label: 'Holidays'
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#riverside-calendar .riverside-holiday-label {
|
#riverside-calendar .riverside-holiday-label {
|
||||||
|
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-bottom: 2px;
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,12 @@
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.className = 'riverside-holiday-label';
|
label.className = 'riverside-holiday-label';
|
||||||
label.textContent = holiday;
|
label.textContent = holiday;
|
||||||
|
const dayTop = arg.el.querySelector('.fc-daygrid-day-top');
|
||||||
|
if (dayTop) {
|
||||||
|
dayTop.insertAdjacentElement('afterend', label);
|
||||||
|
} else {
|
||||||
arg.el.appendChild(label);
|
arg.el.appendChild(label);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
moreLinkClick: function (arg) {
|
moreLinkClick: function (arg) {
|
||||||
arg.jsEvent.preventDefault();
|
arg.jsEvent.preventDefault();
|
||||||
|
|
@ -44,7 +49,14 @@
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
const start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
const start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||||
li.textContent = start + ' – ' + end;
|
const params = new URLSearchParams({
|
||||||
|
start: seg.event.startStr,
|
||||||
|
end: seg.event.endStr,
|
||||||
|
});
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = drupalSettings.riversidePt.bookingUrl + '?' + params.toString();
|
||||||
|
a.textContent = start + ' – ' + end;
|
||||||
|
li.appendChild(a);
|
||||||
panelSlots.appendChild(li);
|
panelSlots.appendChild(li);
|
||||||
});
|
});
|
||||||
openPanel();
|
openPanel();
|
||||||
|
|
|
||||||
17
web/modules/custom/riverside_pt/riverside_pt.module
Normal file
17
web/modules/custom/riverside_pt/riverside_pt.module
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
function riverside_pt_mail(string $key, array &$message, array $params): void {
|
||||||
|
if ($key !== 'booking_request') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = new \DateTime($params['start']);
|
||||||
|
$end = new \DateTime($params['end']);
|
||||||
|
|
||||||
|
$message['subject'] = 'Booking request — ' . $start->format('M j, Y g:i A');
|
||||||
|
$message['body'][] = implode("\n", [
|
||||||
|
'Name: ' . $params['first_name'] . ' ' . $params['last_name'],
|
||||||
|
'Phone: ' . $params['phone'],
|
||||||
|
'Slot: ' . $start->format('l, F j, Y') . ', ' . $start->format('g:i A') . '–' . $end->format('g:i A'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,14 @@ riverside_pt.schedule:
|
||||||
requirements:
|
requirements:
|
||||||
_permission: 'access content'
|
_permission: 'access content'
|
||||||
|
|
||||||
|
riverside_pt.booking:
|
||||||
|
path: '/schedule/book'
|
||||||
|
defaults:
|
||||||
|
_form: '\Drupal\riverside_pt\Form\BookingForm'
|
||||||
|
_title: 'Request Appointment'
|
||||||
|
requirements:
|
||||||
|
_permission: 'access content'
|
||||||
|
|
||||||
riverside_pt.schedule_events:
|
riverside_pt.schedule_events:
|
||||||
path: '/schedule/events'
|
path: '/schedule/events'
|
||||||
defaults:
|
defaults:
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class ScheduleController extends ControllerBase {
|
||||||
'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(),
|
||||||
'holidays' => $this->buildHolidaysMap(),
|
'holidays' => $this->buildHolidaysMap(),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
@ -82,6 +83,10 @@ class ScheduleController extends ControllerBase {
|
||||||
$end = $request->query->get('end');
|
$end = $request->query->get('end');
|
||||||
|
|
||||||
$current = new \DateTime($start ?? 'now');
|
$current = new \DateTime($start ?? 'now');
|
||||||
|
$today = new \DateTime('today');
|
||||||
|
if ($current < $today) {
|
||||||
|
$current = $today;
|
||||||
|
}
|
||||||
$until = new \DateTime($end ?? 'now');
|
$until = new \DateTime($end ?? 'now');
|
||||||
$events = [];
|
$events = [];
|
||||||
$id = 1;
|
$id = 1;
|
||||||
|
|
|
||||||
118
web/modules/custom/riverside_pt/src/Form/BookingForm.php
Normal file
118
web/modules/custom/riverside_pt/src/Form/BookingForm.php
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Drupal\riverside_pt\Form;
|
||||||
|
|
||||||
|
use Drupal\Core\Form\FormBase;
|
||||||
|
use Drupal\Core\Form\FormStateInterface;
|
||||||
|
use Drupal\Core\Mail\MailManagerInterface;
|
||||||
|
use Drupal\Core\Config\ConfigFactoryInterface;
|
||||||
|
use Drupal\user\Entity\User;
|
||||||
|
use Symfony\Component\DependencyInjection\ContainerInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
class BookingForm extends FormBase {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly MailManagerInterface $mailManager,
|
||||||
|
ConfigFactoryInterface $configFactory,
|
||||||
|
RequestStack $requestStack,
|
||||||
|
) {
|
||||||
|
$this->configFactory = $configFactory;
|
||||||
|
$this->requestStack = $requestStack;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(ContainerInterface $container): static {
|
||||||
|
return new static(
|
||||||
|
$container->get('plugin.manager.mail'),
|
||||||
|
$container->get('config.factory'),
|
||||||
|
$container->get('request_stack'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormId(): string {
|
||||||
|
return 'riverside_pt_booking_form';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildForm(array $form, FormStateInterface $form_state): array {
|
||||||
|
$query = $this->requestStack->getCurrentRequest()->query;
|
||||||
|
$start = $query->get('start', '');
|
||||||
|
$end = $query->get('end', '');
|
||||||
|
$uid = $query->get('provider', '');
|
||||||
|
|
||||||
|
$slot_display = '';
|
||||||
|
if ($start && $end) {
|
||||||
|
$s = new \DateTime($start);
|
||||||
|
$e = new \DateTime($end);
|
||||||
|
$slot_display = $s->format('l, F j, Y') . ', ' . $s->format('g:i A') . '–' . $e->format('g:i A');
|
||||||
|
}
|
||||||
|
|
||||||
|
$form['slot_summary'] = [
|
||||||
|
'#type' => 'item',
|
||||||
|
'#title' => $this->t('Appointment'),
|
||||||
|
'#markup' => $slot_display ?: $this->t('No slot selected.'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($uid && $provider = User::load($uid)) {
|
||||||
|
$form['provider_summary'] = [
|
||||||
|
'#type' => 'item',
|
||||||
|
'#title' => $this->t('Provider'),
|
||||||
|
'#markup' => $provider->getDisplayName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$form['start'] = ['#type' => 'hidden', '#value' => $start];
|
||||||
|
$form['end'] = ['#type' => 'hidden', '#value' => $end];
|
||||||
|
$form['provider_id'] = ['#type' => 'hidden', '#value' => $uid];
|
||||||
|
|
||||||
|
$form['first_name'] = [
|
||||||
|
'#type' => 'textfield',
|
||||||
|
'#title' => $this->t('First name'),
|
||||||
|
'#required' => TRUE,
|
||||||
|
];
|
||||||
|
|
||||||
|
$form['last_name'] = [
|
||||||
|
'#type' => 'textfield',
|
||||||
|
'#title' => $this->t('Last name'),
|
||||||
|
'#required' => TRUE,
|
||||||
|
];
|
||||||
|
|
||||||
|
$form['phone'] = [
|
||||||
|
'#type' => 'tel',
|
||||||
|
'#title' => $this->t('Phone number'),
|
||||||
|
'#required' => TRUE,
|
||||||
|
];
|
||||||
|
|
||||||
|
$form['actions'] = ['#type' => 'actions'];
|
||||||
|
$form['actions']['submit'] = [
|
||||||
|
'#type' => 'submit',
|
||||||
|
'#value' => $this->t('Request appointment'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function submitForm(array &$form, FormStateInterface $form_state): void {
|
||||||
|
$to = $this->configFactory->get('riverside_pt.settings')->get('notification_email');
|
||||||
|
$lang = $this->languageManager()->getDefaultLanguage()->getId();
|
||||||
|
|
||||||
|
$params = [
|
||||||
|
'first_name' => $form_state->getValue('first_name'),
|
||||||
|
'last_name' => $form_state->getValue('last_name'),
|
||||||
|
'phone' => $form_state->getValue('phone'),
|
||||||
|
'start' => $form_state->getValue('start'),
|
||||||
|
'end' => $form_state->getValue('end'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$sent = $this->mailManager->mail('riverside_pt', 'booking_request', $to, $lang, $params);
|
||||||
|
|
||||||
|
if ($sent['result']) {
|
||||||
|
$this->messenger()->addStatus($this->t('Your request has been submitted. We will contact you to confirm.'));
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$this->messenger()->addError($this->t('Something went wrong. Please call us to book directly.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$form_state->setRedirect('riverside_pt.schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,11 @@ $settings['hash_salt'] = getenv('HASH_SALT') ?: 'replace-this-in-production';
|
||||||
|
|
||||||
$settings['update_free_access'] = FALSE;
|
$settings['update_free_access'] = FALSE;
|
||||||
|
|
||||||
|
if ($postmark_key = getenv('POSTMARK_API_KEY')) {
|
||||||
|
$config['symfony_mailer.mailer_transport.postmark']['configuration']['dsn'] =
|
||||||
|
'postmark+api://' . $postmark_key . '@default';
|
||||||
|
}
|
||||||
|
|
||||||
// Disable CSS/JS aggregation — assets served directly from source paths.
|
// Disable CSS/JS aggregation — assets served directly from source paths.
|
||||||
$config['system.performance']['css']['preprocess'] = FALSE;
|
$config['system.performance']['css']['preprocess'] = FALSE;
|
||||||
$config['system.performance']['js']['preprocess'] = FALSE;
|
$config['system.performance']['js']['preprocess'] = FALSE;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue