diff --git a/README.md b/README.md index 41ae4fc..c47aa99 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,30 @@ make shell # open a bash shell in the app container make drush # 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 diff --git a/composer.json b/composer.json index dd36876..bff7ed9 100644 --- a/composer.json +++ b/composer.json @@ -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, diff --git a/config/sync/symfony_mailer.mailer_transport.postmark.yml b/config/sync/symfony_mailer.mailer_transport.postmark.yml new file mode 100644 index 0000000..cd9b3ff --- /dev/null +++ b/config/sync/symfony_mailer.mailer_transport.postmark.yml @@ -0,0 +1,8 @@ +langcode: en +status: true +dependencies: {} +id: postmark +label: Postmark +plugin: dsn +configuration: + dsn: 'postmark+api://change-me@default' diff --git a/docker-compose.yml b/docker-compose.yml index 1787e9f..4a3fcf1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/scripts/seed_availability.php b/scripts/seed_availability.php new file mode 100644 index 0000000..75b787a --- /dev/null +++ b/scripts/seed_availability.php @@ -0,0 +1,133 @@ +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') +); diff --git a/web/modules/custom/riverside_pt/config/install/riverside_pt.settings.yml b/web/modules/custom/riverside_pt/config/install/riverside_pt.settings.yml index 9f334c6..8b7a52b 100644 --- a/web/modules/custom/riverside_pt/config/install/riverside_pt.settings.yml +++ b/web/modules/custom/riverside_pt/config/install/riverside_pt.settings.yml @@ -1,3 +1,4 @@ +notification_email: 'admin@example.com' holidays: - date: '2026-01-01' name: "New Year's Day" diff --git a/web/modules/custom/riverside_pt/config/schema/riverside_pt.schema.yml b/web/modules/custom/riverside_pt/config/schema/riverside_pt.schema.yml index 9b90132..e14eecb 100644 --- a/web/modules/custom/riverside_pt/config/schema/riverside_pt.schema.yml +++ b/web/modules/custom/riverside_pt/config/schema/riverside_pt.schema.yml @@ -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' diff --git a/web/modules/custom/riverside_pt/css/calendar.css b/web/modules/custom/riverside_pt/css/calendar.css index 3c0f3d1..aada387 100644 --- a/web/modules/custom/riverside_pt/css/calendar.css +++ b/web/modules/custom/riverside_pt/css/calendar.css @@ -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; } diff --git a/web/modules/custom/riverside_pt/js/calendar.js b/web/modules/custom/riverside_pt/js/calendar.js index 7058ee5..335f6f1 100644 --- a/web/modules/custom/riverside_pt/js/calendar.js +++ b/web/modules/custom/riverside_pt/js/calendar.js @@ -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(); diff --git a/web/modules/custom/riverside_pt/riverside_pt.module b/web/modules/custom/riverside_pt/riverside_pt.module new file mode 100644 index 0000000..0f62ac4 --- /dev/null +++ b/web/modules/custom/riverside_pt/riverside_pt.module @@ -0,0 +1,17 @@ +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'), + ]); +} diff --git a/web/modules/custom/riverside_pt/riverside_pt.routing.yml b/web/modules/custom/riverside_pt/riverside_pt.routing.yml index 295bf00..8c18dd5 100644 --- a/web/modules/custom/riverside_pt/riverside_pt.routing.yml +++ b/web/modules/custom/riverside_pt/riverside_pt.routing.yml @@ -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: diff --git a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php index de40cae..684eec0 100644 --- a/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php +++ b/web/modules/custom/riverside_pt/src/Controller/ScheduleController.php @@ -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; diff --git a/web/modules/custom/riverside_pt/src/Form/BookingForm.php b/web/modules/custom/riverside_pt/src/Form/BookingForm.php new file mode 100644 index 0000000..afd1cec --- /dev/null +++ b/web/modules/custom/riverside_pt/src/Form/BookingForm.php @@ -0,0 +1,118 @@ +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'); + } + +} diff --git a/web/sites/default/settings.php b/web/sites/default/settings.php index 912fbb1..c936315 100644 --- a/web/sites/default/settings.php +++ b/web/sites/default/settings.php @@ -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;