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
|
||||
```
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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"
|
||||
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:
|
||||
|
|
|
|||
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:
|
||||
- date: '2026-01-01'
|
||||
name: "New Year's Day"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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:
|
||||
_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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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;
|
||||
|
||||
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue