From fba9edc9312a694a470ce099645e02272c798e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 2 Oct 2025 17:57:56 +0200 Subject: [PATCH] feat: inform user about automatic reassignments or reset selections --- assets/controllers/toast_controller.js | 51 +++++++++++++++++++ assets/styles/_components.css | 3 +- assets/styles/components/toast.css | 20 ++++++++ package-lock.json | 9 +++- package.json | 3 +- .../Booking/CreateStep2Controller.php | 38 +++++++++++++- src/Form/Model/ParticipantDto.php | 19 +++++++ .../ParticipantInsuranceFieldHandler.php | 15 ++++++ .../ParticipantRentalsFieldHandler.php | 19 ++++++- templates/booking/create_step_2.html.twig | 1 + 10 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 assets/controllers/toast_controller.js create mode 100644 assets/styles/components/toast.css diff --git a/assets/controllers/toast_controller.js b/assets/controllers/toast_controller.js new file mode 100644 index 0000000..969e96d --- /dev/null +++ b/assets/controllers/toast_controller.js @@ -0,0 +1,51 @@ +import { Controller } from '@hotwired/stimulus' +import Toastify from 'toastify-js' + +export default class extends Controller { + + static values = { text: String, class: String } + + connect() { + // Show toast from Stimulus values (existing functionality) + if (this.hasTextValue) { + this.showToast(this.textValue, this.classValue) + } + + // Listen for HTMX notification events + document.addEventListener('showNotifications', this.handleNotifications.bind(this)) + } + + disconnect() { + document.removeEventListener('showNotifications', this.handleNotifications.bind(this)) + } + + handleNotifications(event) { + const notifications = event.detail?.notifications || [] + + notifications.forEach(notification => { + const className = this.getClassForType(notification.type) + this.showToast(notification.message, className) + }) + } + + showToast(text, className = '') { + Toastify({ + duration: 5000, + text: text, + gravity: 'top', + position: 'right', + className: className, + close: true, + }).showToast() + } + + getClassForType(type) { + const typeMap = { + 'success': 'toastify--success', + 'warning': 'toastify--warning', + 'info': 'toastify--info', + } + + return typeMap[type] || 'toastify--info' + } +} \ No newline at end of file diff --git a/assets/styles/_components.css b/assets/styles/_components.css index f585773..b0ca78a 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -3,4 +3,5 @@ @import "components/button.css"; @import "components/menu.css"; @import "components/tooltip.css"; -@import "components/table-responsive.css"; \ No newline at end of file +@import "components/table-responsive.css"; +@import "components/toast.css"; diff --git a/assets/styles/components/toast.css b/assets/styles/components/toast.css new file mode 100644 index 0000000..5d9734b --- /dev/null +++ b/assets/styles/components/toast.css @@ -0,0 +1,20 @@ +@import "toastify-js/src/toastify.css"; + +.toastify { + @apply shadow-lg rounded; +} + +.toastify--success { + @apply text-gray-100; + background: theme('colors.emerald.600'); +} + +.toastify--warning { + @apply text-gray-100; + background: theme('colors.red.600'); +} + +.toastify--info { + @apply text-gray-100; + background: theme('colors.primary'); +} diff --git a/package-lock.json b/package-lock.json index c7190ea..8526d1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "license": "WTFPL", "dependencies": { "@iframe-resizer/child": "^5.3.2", - "tippy.js": "^6.3.7" + "tippy.js": "^6.3.7", + "toastify-js": "^1.12.0" }, "devDependencies": { "@babel/core": "^7.17.0", @@ -6537,6 +6538,12 @@ "node": ">=8.0" } }, + "node_modules/toastify-js": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/toastify-js/-/toastify-js-1.12.0.tgz", + "integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/package.json b/package.json index 7d64a71..902a3c7 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@iframe-resizer/child": "^5.3.2", - "tippy.js": "^6.3.7" + "tippy.js": "^6.3.7", + "toastify-js": "^1.12.0" } } diff --git a/src/Controller/Booking/CreateStep2Controller.php b/src/Controller/Booking/CreateStep2Controller.php index 0bae619..0a34216 100644 --- a/src/Controller/Booking/CreateStep2Controller.php +++ b/src/Controller/Booking/CreateStep2Controller.php @@ -131,6 +131,9 @@ class CreateStep2Controller extends AbstractController $this->bookingService->saveBookingCreateDto($request, $bookingCreateDto); + // Collect notifications from all participants + $notifications = $this->collectParticipantNotifications($bookingCreateDto); + $summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto); $roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto); $availableRooms = $bookingCreateDto->travel->getAvailableRooms(); @@ -139,7 +142,7 @@ class CreateStep2Controller extends AbstractController // The DTO is now updated with the latest selection and submitted data has been cleaned. // We can now render the blocks with the fresh data. - return $this->htmxOobResponse( + $response = $this->htmxOobResponse( 'booking/create_step_2.html.twig', ['participants_form', 'booking_summary'], [ @@ -152,6 +155,15 @@ class CreateStep2Controller extends AbstractController 'groupedSelectedRooms' => $groupedSelectedRooms, ] ); + + // Add notifications to HTMX trigger header if any exist + if ([] !== $notifications) { + $response->headers->set('HX-Trigger', json_encode([ + 'showNotifications' => ['notifications' => $notifications], + ])); + } + + return $response; } /** @@ -233,4 +245,28 @@ class CreateStep2Controller extends AbstractController $this->roomAssignmentService->assignParticipantsToRooms($bookingCreateDto); } } + + /** + * Collects all notifications from participants and clears them. + * + * @param BookingCreateDto $bookingCreateDto The booking DTO containing participants + * + * @return array Array of notification messages + */ + private function collectParticipantNotifications(BookingCreateDto $bookingCreateDto): array + { + $notifications = []; + + foreach ($bookingCreateDto->participants as $participant) { + if ([] !== $participant->notifications) { + foreach ($participant->notifications as $notification) { + $notifications[] = $notification; + } + // Clear notifications after collection + $participant->notifications = []; + } + } + + return $notifications; + } } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 0ab6a5f..cfe7c4e 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -77,6 +77,11 @@ class ParticipantDto // Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants) public bool $bulkInsuranceBooking = false; + /** + * @var array Notification messages for user feedback + */ + public array $notifications = []; + public static function fromPersonalData(PersonalData $personalData): static { $instance = new static(); @@ -164,4 +169,18 @@ class ParticipantDto { return $this->insurance?->price ?? 0.0; } + + /** + * Adds a notification message for user feedback. + * + * @param string $type The notification type (info, warning, success) + * @param string $message The notification message + */ + public function addNotification(string $type, string $message): void + { + $this->notifications[] = [ + 'type' => $type, + 'message' => $message, + ]; + } } diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index c664450..737d2c5 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -151,6 +151,13 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler $bookingDto ); $participant->insurance = $reassignedInsurance; + + if (null !== $reassignedInsurance) { + $participant->addNotification( + 'info', + sprintf('Versicherung automatisch angepasst: %s', $reassignedInsurance->label) + ); + } } } else { // Insurance not found - clear selection @@ -174,6 +181,14 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler $participant, $bookingDto ); + + if (null !== $reassignedInsurance && $reassignedInsurance->id !== $currentInsurance->id) { + $participant->addNotification( + 'info', + sprintf('Versicherung automatisch angepasst an neuen Preis: %s', $reassignedInsurance->label) + ); + } + $participant->insurance = $reassignedInsurance; // null if no suitable match found } diff --git a/src/Form/Service/ParticipantRentalsFieldHandler.php b/src/Form/Service/ParticipantRentalsFieldHandler.php index a247690..730fdac 100644 --- a/src/Form/Service/ParticipantRentalsFieldHandler.php +++ b/src/Form/Service/ParticipantRentalsFieldHandler.php @@ -64,9 +64,18 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler return; } + // Store previous rental state BEFORE checking skipass + $previousRentals = $participant->rentals; + // Check if rentals should be available based on skipass selection if (null === $participant->skiPass) { // No skipass selected = clear all rental selections + if ([] !== $previousRentals) { + $participant->addNotification( + 'warning', + 'Ausrüstung wurde entfernt (kein Skipass ausgewählt)' + ); + } $participant->rentals = []; return; @@ -85,6 +94,14 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler $participantIndex ); + // Notify if rentals were cleared due to skipass duration change + if ([] !== $previousRentals && [] === $validSelections) { + $participant->addNotification( + 'warning', + 'Ausrüstung wurde entfernt (andere Skipass-Dauer ausgewählt)' + ); + } + $participant->rentals = $validSelections; } @@ -168,7 +185,7 @@ class ParticipantRentalsFieldHandler extends AbstractParticipantFieldHandler * - rental.dateFrom === skipass.dateFrom * - rental.dateTo === skipass.dateTo * - * @param array $rentals All available rental services + * @param array $rentals All available rental services * @param \App\Form\Model\ParticipantDto $participant The participant with skipass selection * * @return array Filtered rentals matching the skipass duration diff --git a/templates/booking/create_step_2.html.twig b/templates/booking/create_step_2.html.twig index fe3eab7..36d3d25 100644 --- a/templates/booking/create_step_2.html.twig +++ b/templates/booking/create_step_2.html.twig @@ -52,6 +52,7 @@ {% block content %} {% include '_partials/_flashes.html.twig' %} +

Neue Buchung