wip booking flow

This commit is contained in:
Mork Swork 2026-05-13 14:55:52 -07:00
parent 269813c12d
commit 0a2e80f7b0
14 changed files with 343 additions and 6 deletions

View file

@ -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
```
## 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
- **FullCalendar View** — interactive appointment calendar

View file

@ -5,6 +5,10 @@
"license": "GPL-2.0-or-later",
"minimum-stability": "dev",
"prefer-stable": true,
"require": {
"symfony/http-client": "^7.0",
"symfony/postmark-mailer": "^7.0"
},
"config": {
"allow-plugins": {
"composer/installers": true,

View file

@ -0,0 +1,8 @@
langcode: en
status: true
dependencies: {}
id: postmark
label: Postmark
plugin: dsn
configuration:
dsn: 'postmark+api://change-me@default'

View file

@ -13,6 +13,7 @@ services:
SITE_NAME: "Portfolio"
ADMIN_PASS: "${ADMIN_PASS:-admin}"
HASH_SALT: "${HASH_SALT:-replace-this-in-production-with-a-long-random-string}"
POSTMARK_API_KEY: "${POSTMARK_API_KEY:-}"
volumes:
- ./web/sites/default/files:/var/www/html/web/sites/default/files
depends_on:

View 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: 8am4pm (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 05, weighted toward 13.
$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')
);

View file

@ -1,3 +1,4 @@
notification_email: 'admin@example.com'
holidays:
- date: '2026-01-01'
name: "New Year's Day"

View file

@ -2,6 +2,9 @@ riverside_pt.settings:
type: config_object
label: 'Riverside PT Settings'
mapping:
notification_email:
type: string
label: 'Notification email address'
holidays:
type: sequence
label: 'Holidays'

View file

@ -79,11 +79,9 @@
}
#riverside-calendar .riverside-holiday-label {
line-height: 1;
font-size: 0.65rem;
color: #b45309;
text-align: center;
padding-bottom: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

View file

@ -32,7 +32,12 @@
const label = document.createElement('div');
label.className = 'riverside-holiday-label';
label.textContent = holiday;
arg.el.appendChild(label);
const dayTop = arg.el.querySelector('.fc-daygrid-day-top');
if (dayTop) {
dayTop.insertAdjacentElement('afterend', label);
} else {
arg.el.appendChild(label);
}
},
moreLinkClick: function (arg) {
arg.jsEvent.preventDefault();
@ -44,7 +49,14 @@
const li = document.createElement('li');
const start = seg.event.start.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
const end = seg.event.end.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
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);
});
openPanel();

View 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'),
]);
}

View file

@ -6,6 +6,14 @@ riverside_pt.schedule:
requirements:
_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:
path: '/schedule/events'
defaults:

View file

@ -60,7 +60,8 @@ class ScheduleController extends ControllerBase {
'library' => ['riverside_pt/schedule'],
'drupalSettings' => [
'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(),
],
],
@ -82,6 +83,10 @@ class ScheduleController extends ControllerBase {
$end = $request->query->get('end');
$current = new \DateTime($start ?? 'now');
$today = new \DateTime('today');
if ($current < $today) {
$current = $today;
}
$until = new \DateTime($end ?? 'now');
$events = [];
$id = 1;

View 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');
}
}

View file

@ -19,6 +19,11 @@ $settings['hash_salt'] = getenv('HASH_SALT') ?: 'replace-this-in-production';
$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.
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;