# Story 11.2: Public Booking Form with Custom Captcha ## Epic Reference **Epic 11:** Guest Booking ## Story Context This story implements the main guest-facing booking interface at `/booking`. It replaces the placeholder page with a functional booking form that includes the availability calendar, guest contact fields, custom captcha for spam protection, and 1-per-day limit enforcement. ## User Story As a **website visitor**, I want **to request a consultation without creating an account**, So that **I can easily reach out to the lawyer for legal assistance**. ## Acceptance Criteria ### Public Booking Page - [ ] `/booking` route displays guest booking form for unauthenticated visitors - [ ] Logged-in users redirected to `/client/consultations/book` - [ ] Page uses public layout (`x-layouts.public`) - [ ] Bilingual support (Arabic/English) - [ ] Mobile responsive design ### Availability Calendar Integration - [ ] Reuses existing `availability-calendar` Livewire component - [ ] Shows available time slots (same as client view) - [ ] Blocked/booked times not selectable ### Guest Contact Form - [ ] Full name field (required, min 3 chars, max 255) - [ ] Email field (required, valid email format) - [ ] Phone field (required, valid format) - [ ] Problem summary field (required, min 20 chars, max 2000) - [ ] All fields have clear labels and validation messages - [ ] Form follows Flux UI patterns ### Custom Captcha System - [ ] Math-based captcha (e.g., "What is 7 + 3?") - [ ] Question generated server-side, answer stored in session - [ ] User must enter correct answer to submit - [ ] Refresh button to get new captcha question - [ ] Captcha validates before form submission - [ ] Bilingual captcha labels ### 1-Per-Day Limit - [ ] Check if email has pending/approved booking for selected date - [ ] If limit reached, show error message and prevent submission - [ ] Clear error message explaining the limit - [ ] Limit applies per email address per calendar day ### Rate Limiting (Backup) - [ ] Maximum 5 booking attempts per IP per 24 hours - [ ] Rate limit error shown if exceeded ### Submission Flow - [ ] Step 1: Select date/time from calendar - [ ] Step 2: Fill contact info + problem summary + captcha - [ ] Step 3: Confirmation screen showing all details - [ ] Step 4: Submit creates guest consultation (status: pending) - [ ] Success message with instructions to check email ## Implementation Steps ### Step 1: Create Captcha Service Create `app/Services/CaptchaService.php`: ```php $answer]); return [ 'question' => "What is {$num1} + {$num2}?", 'question_ar' => "ما هو {$num1} + {$num2}؟", ]; } /** * Validate the user's captcha answer. */ public function validate(mixed $answer): bool { $expected = session(self::SESSION_KEY); if (is_null($expected)) { return false; } return (int) $answer === (int) $expected; } /** * Clear the current captcha from session. */ public function clear(): void { session()->forget(self::SESSION_KEY); } } ``` ### Step 2: Create Guest Booking Volt Component Create `resources/views/livewire/pages/booking.php`: ```php check()) { $this->redirect(route('client.consultations.book')); return; } $this->refreshCaptcha(); } public function refreshCaptcha(): void { $this->captchaQuestion = app(CaptchaService::class)->generate(); $this->captchaAnswer = ''; } public function selectSlot(string $date, string $time): void { $this->selectedDate = $date; $this->selectedTime = $time; } public function clearSelection(): void { $this->selectedDate = null; $this->selectedTime = null; $this->showConfirmation = false; } public function showConfirm(): void { $this->validate([ 'selectedDate' => ['required', 'date', 'after_or_equal:today'], 'selectedTime' => ['required'], 'guestName' => ['required', 'string', 'min:3', 'max:255'], 'guestEmail' => ['required', 'email', 'max:255'], 'guestPhone' => ['required', 'string', 'max:50'], 'problemSummary' => ['required', 'string', 'min:20', 'max:2000'], 'captchaAnswer' => ['required'], ]); // Validate captcha if (!app(CaptchaService::class)->validate($this->captchaAnswer)) { $this->addError('captchaAnswer', __('booking.invalid_captcha')); $this->refreshCaptcha(); return; } // Check 1-per-day limit for this email $existingBooking = Consultation::query() ->where('guest_email', $this->guestEmail) ->whereDate('booking_date', $this->selectedDate) ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved]) ->exists(); if ($existingBooking) { $this->addError('guestEmail', __('booking.guest_already_booked_this_day')); return; } // Verify slot still available $service = app(AvailabilityService::class); $availableSlots = $service->getAvailableSlots(Carbon::parse($this->selectedDate)); if (!in_array($this->selectedTime, $availableSlots)) { $this->addError('selectedTime', __('booking.slot_no_longer_available')); return; } $this->showConfirmation = true; } public function submit(): void { // Rate limiting by IP $ipKey = 'guest-booking:' . request()->ip(); if (RateLimiter::tooManyAttempts($ipKey, 5)) { $this->addError('guestEmail', __('booking.too_many_attempts')); return; } RateLimiter::hit($ipKey, 60 * 60 * 24); // 24 hours try { DB::transaction(function () { // Double-check slot availability with lock $slotTaken = Consultation::query() ->whereDate('booking_date', $this->selectedDate) ->where('booking_time', $this->selectedTime) ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved]) ->lockForUpdate() ->exists(); if ($slotTaken) { throw new \Exception(__('booking.slot_taken')); } // Double-check 1-per-day with lock $emailHasBooking = Consultation::query() ->where('guest_email', $this->guestEmail) ->whereDate('booking_date', $this->selectedDate) ->whereIn('status', [ConsultationStatus::Pending, ConsultationStatus::Approved]) ->lockForUpdate() ->exists(); if ($emailHasBooking) { throw new \Exception(__('booking.guest_already_booked_this_day')); } // Create guest consultation $consultation = Consultation::create([ 'user_id' => null, 'guest_name' => $this->guestName, 'guest_email' => $this->guestEmail, 'guest_phone' => $this->guestPhone, 'booking_date' => $this->selectedDate, 'booking_time' => $this->selectedTime, 'problem_summary' => $this->problemSummary, 'status' => ConsultationStatus::Pending, 'payment_status' => PaymentStatus::NotApplicable, ]); // Send confirmation to guest Mail::to($this->guestEmail)->queue( new GuestBookingSubmittedMail($consultation) ); // Notify admin $admin = User::query()->where('user_type', 'admin')->first(); if ($admin) { Mail::to($admin)->queue( new NewBookingAdminEmail($consultation) ); } }); // Clear captcha app(CaptchaService::class)->clear(); session()->flash('success', __('booking.guest_submitted_successfully')); $this->redirect(route('booking.success')); } catch (\Exception $e) { $this->addError('selectedTime', $e->getMessage()); $this->showConfirmation = false; $this->refreshCaptcha(); } } }; ?>
{{ __('booking.request_consultation') }} @if(session('success')) {{ session('success') }} @endif @if(!$selectedDate || !$selectedTime) {{-- Step 1: Calendar Selection --}}

{{ __('booking.guest_intro') }}

{{ __('booking.select_date_time') }}

@else {{-- Step 2+: Contact Form & Confirmation --}}

{{ __('booking.selected_time') }}

{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}

{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}

{{ __('common.change') }}
@if(!$showConfirmation) {{-- Contact Form --}}
{{ __('booking.guest_name') }} {{ __('booking.guest_email') }} {{ __('booking.guest_phone') }} {{ __('booking.problem_summary') }} {{ __('booking.problem_summary_help') }} {{-- Custom Captcha --}} {{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }}
{{ __('booking.continue') }} {{ __('common.loading') }}
@else {{-- Confirmation Step --}} {{ __('booking.confirm_booking') }}

{{ __('booking.confirm_message') }}

{{ __('booking.guest_name') }}: {{ $guestName }}

{{ __('booking.guest_email') }}: {{ $guestEmail }}

{{ __('booking.guest_phone') }}: {{ $guestPhone }}

{{ __('booking.date') }}: {{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}

{{ __('booking.time') }}: {{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}

{{ __('booking.duration') }}: 45 {{ __('common.minutes') }}

{{ __('booking.problem_summary') }}:

{{ $problemSummary }}

{{ __('common.back') }} {{ __('booking.submit_request') }} {{ __('common.submitting') }}
@error('selectedTime') {{ $message }} @enderror @endif @endif
``` ### Step 3: Update Routes Update `routes/web.php`: ```php // Replace placeholder route with Volt component Volt::route('/booking', 'pages.booking')->name('booking'); Volt::route('/booking/success', 'pages.booking-success')->name('booking.success'); ``` ### Step 4: Create Success Page Create `resources/views/livewire/pages/booking-success.php`: ```php
{{ __('booking.success_title') }}

{{ __('booking.success_message') }}

{{ __('navigation.home') }}
``` ### Step 5: Add Translation Keys Add to `lang/en/booking.php`: ```php 'guest_intro' => 'Request a consultation appointment. No account required - simply fill in your details below.', 'guest_name' => 'Full Name', 'guest_email' => 'Email Address', 'guest_phone' => 'Phone Number', 'guest_already_booked_this_day' => 'This email already has a booking request for the selected date. Please choose a different date.', 'guest_submitted_successfully' => 'Your booking request has been submitted. Please check your email for confirmation.', 'invalid_captcha' => 'Incorrect answer. Please try again.', 'too_many_attempts' => 'Too many booking attempts. Please try again later.', 'success_title' => 'Booking Request Submitted!', 'success_message' => 'We have received your consultation request. You will receive an email confirmation shortly. Our team will review your request and contact you.', ``` Add to `lang/ar/booking.php`: ```php 'guest_intro' => 'اطلب موعد استشارة. لا حاجة لإنشاء حساب - ما عليك سوى ملء بياناتك أدناه.', 'guest_name' => 'الاسم الكامل', 'guest_email' => 'البريد الإلكتروني', 'guest_phone' => 'رقم الهاتف', 'guest_already_booked_this_day' => 'هذا البريد الإلكتروني لديه طلب حجز بالفعل للتاريخ المحدد. يرجى اختيار تاريخ آخر.', 'guest_submitted_successfully' => 'تم تقديم طلب الحجز الخاص بك. يرجى التحقق من بريدك الإلكتروني للتأكيد.', 'invalid_captcha' => 'إجابة خاطئة. يرجى المحاولة مرة أخرى.', 'too_many_attempts' => 'محاولات حجز كثيرة جداً. يرجى المحاولة لاحقاً.', 'success_title' => 'تم تقديم طلب الحجز!', 'success_message' => 'لقد تلقينا طلب الاستشارة الخاص بك. ستتلقى رسالة تأكيد عبر البريد الإلكتروني قريباً. سيقوم فريقنا بمراجعة طلبك والتواصل معك.', ``` ## Testing Requirements ### Feature Tests ```php test('guest can view booking page', function () { $this->get(route('booking')) ->assertOk() ->assertSee(__('booking.request_consultation')); }); test('logged in user is redirected to client booking', function () { $user = User::factory()->client()->create(); $this->actingAs($user) ->get(route('booking')) ->assertRedirect(route('client.consultations.book')); }); test('guest can submit booking request', function () { // Setup working hours and available slot WorkingHour::factory()->create([ 'day_of_week' => now()->addDay()->dayOfWeek, 'is_active' => true, 'start_time' => '09:00', 'end_time' => '17:00', ]); $date = now()->addDay()->format('Y-m-d'); Volt::test('pages.booking') ->call('selectSlot', $date, '09:00') ->set('guestName', 'John Doe') ->set('guestEmail', 'john@example.com') ->set('guestPhone', '+970599123456') ->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.') ->set('captchaAnswer', session('captcha_answer')) ->call('showConfirm') ->assertSet('showConfirmation', true) ->call('submit') ->assertRedirect(route('booking.success')); $this->assertDatabaseHas('consultations', [ 'guest_email' => 'john@example.com', 'guest_name' => 'John Doe', 'user_id' => null, ]); }); test('guest cannot book twice on same day', function () { $date = now()->addDay()->format('Y-m-d'); // Create existing booking for this email Consultation::factory()->guest()->create([ 'guest_email' => 'john@example.com', 'booking_date' => $date, 'status' => ConsultationStatus::Pending, ]); Volt::test('pages.booking') ->call('selectSlot', $date, '10:00') ->set('guestName', 'John Doe') ->set('guestEmail', 'john@example.com') ->set('guestPhone', '+970599123456') ->set('problemSummary', 'Another consultation request for testing purposes.') ->set('captchaAnswer', session('captcha_answer')) ->call('showConfirm') ->assertHasErrors(['guestEmail']); }); test('invalid captcha prevents submission', function () { Volt::test('pages.booking') ->call('selectSlot', now()->addDay()->format('Y-m-d'), '09:00') ->set('guestName', 'John Doe') ->set('guestEmail', 'john@example.com') ->set('guestPhone', '+970599123456') ->set('problemSummary', 'I need legal advice regarding a contract dispute.') ->set('captchaAnswer', 'wrong-answer') ->call('showConfirm') ->assertHasErrors(['captchaAnswer']); }); test('rate limiting prevents excessive booking attempts', function () { $ipKey = 'guest-booking:127.0.0.1'; // Exhaust the rate limit (5 attempts) for ($i = 0; $i < 5; $i++) { RateLimiter::hit($ipKey, 60 * 60 * 24); } WorkingHour::factory()->create([ 'day_of_week' => now()->addDay()->dayOfWeek, 'is_active' => true, 'start_time' => '09:00', 'end_time' => '17:00', ]); $date = now()->addDay()->format('Y-m-d'); $component = Volt::test('pages.booking') ->call('selectSlot', $date, '09:00') ->set('guestName', 'John Doe') ->set('guestEmail', 'john@example.com') ->set('guestPhone', '+970599123456') ->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.') ->set('captchaAnswer', session('captcha_answer')) ->call('showConfirm') ->call('submit') ->assertHasErrors(['guestEmail']); RateLimiter::clear($ipKey); }); test('slot taken during submission shows error', function () { WorkingHour::factory()->create([ 'day_of_week' => now()->addDay()->dayOfWeek, 'is_active' => true, 'start_time' => '09:00', 'end_time' => '17:00', ]); $date = now()->addDay()->format('Y-m-d'); // Start the booking process $component = Volt::test('pages.booking') ->call('selectSlot', $date, '09:00') ->set('guestName', 'John Doe') ->set('guestEmail', 'john@example.com') ->set('guestPhone', '+970599123456') ->set('problemSummary', 'I need legal advice regarding a contract dispute with my employer.') ->set('captchaAnswer', session('captcha_answer')) ->call('showConfirm') ->assertSet('showConfirmation', true); // Simulate another booking taking the slot before submission Consultation::factory()->guest()->create([ 'booking_date' => $date, 'booking_time' => '09:00', 'status' => ConsultationStatus::Pending, ]); // Try to submit - should fail with slot taken error $component->call('submit') ->assertHasErrors(['selectedTime']); }); ``` ## Dependencies - Story 11.1 (Database Schema & Model Updates) - provides guest fields on Consultation model - Story 11.3 (Guest Notifications) - provides `GuestBookingSubmittedMail` and `NewBookingAdminEmail` mailable classes **Note:** The mailable classes used in this story (`GuestBookingSubmittedMail`, `NewBookingAdminEmail`) are created in Story 11.3. During implementation, either implement Story 11.3 first or create stub mailable classes temporarily. ## Definition of Done - [x] Guest booking form functional at `/booking` - [x] Logged-in users redirected to client booking - [x] Availability calendar shows correct slots - [x] Contact form validates all fields - [x] Custom captcha prevents automated submissions - [x] 1-per-day limit enforced by email - [x] IP rate limiting working - [x] Success page displays after submission - [x] All translations in place (Arabic/English) - [x] Mobile responsive - [x] All tests pass --- ## Dev Agent Record ### Agent Model Used Claude Opus 4.5 (claude-opus-4-5-20251101) ### Completion Notes - Created `CaptchaService` for math-based captcha generation and validation - Created `GuestBookingSubmittedMail` mailable with bilingual email templates - Created guest booking Volt component at `pages/booking.blade.php` with Layout attribute pattern - Created success page at `pages/booking-success.blade.php` - Updated routes to use Volt routes for `/booking` and `/booking/success` - Added all required translation keys for both English and Arabic - Implemented 16 feature tests covering all acceptance criteria - All tests pass (16 tests, 52 assertions) ### File List **Created:** - `app/Services/CaptchaService.php` - `app/Mail/GuestBookingSubmittedMail.php` - `resources/views/emails/booking/guest-submitted/en.blade.php` - `resources/views/emails/booking/guest-submitted/ar.blade.php` - `resources/views/livewire/pages/booking.blade.php` - `resources/views/livewire/pages/booking-success.blade.php` - `tests/Feature/Public/GuestBookingTest.php` **Modified:** - `routes/web.php` - Updated booking routes to use Volt - `lang/en/booking.php` - Added guest booking translations - `lang/ar/booking.php` - Added guest booking translations ### Change Log | Date | Change | Reason | |------|--------|--------| | 2026-01-03 | Initial implementation | Story 11.2 development | --- ## QA Results ### Review Date: 2026-01-03 ### Reviewed By: Quinn (Test Architect) ### Code Quality Assessment **Overall: Strong Implementation** - The guest booking form implementation demonstrates solid engineering practices with proper attention to security, race condition handling, and user experience. The code follows Laravel/Livewire best practices and the project's coding standards. **Strengths Identified:** 1. **Robust Concurrency Control**: The use of `lockForUpdate()` in database transactions prevents race conditions for both slot booking and 1-per-day email limit - excellent defensive coding 2. **Comprehensive Validation**: Multi-layer validation at both `showConfirm()` and `submit()` stages provides proper user feedback and data integrity 3. **Clean Architecture**: CaptchaService is well-encapsulated with single responsibility 4. **Proper Rate Limiting**: IP-based rate limiting (5 attempts/24h) provides spam protection 5. **Bilingual Support**: All user-facing strings use translation helpers, email templates available in both languages 6. **Test Coverage**: 16 comprehensive tests covering all acceptance criteria and edge cases ### Requirements Traceability | AC # | Acceptance Criteria | Test Coverage | Status | |------|---------------------|---------------|--------| | 1 | `/booking` route displays guest booking form | `guest can view booking page` | ✓ | | 2 | Logged-in users redirected to `/client/consultations/book` | `logged in user is redirected to client booking` | ✓ | | 3 | Page uses public layout | Uses `#[Layout('components.layouts.public')]` | ✓ | | 4 | Bilingual support (Arabic/English) | Translation files verified | ✓ | | 5 | Mobile responsive design | Flux UI + min-h-[44px] touch targets | ✓ | | 6 | Reuses existing availability-calendar component | `` used | ✓ | | 7 | Contact form validates all fields | `form validation requires all fields` | ✓ | | 8 | Name min 3 chars | `guest name must be at least 3 characters` | ✓ | | 9 | Problem summary min 20 chars | `problem summary must be at least 20 characters` | ✓ | | 10 | Custom captcha (math-based) | CaptchaService with session storage | ✓ | | 11 | Captcha refresh button | `refreshCaptcha()` method, `guest can refresh captcha` test | ✓ | | 12 | Captcha validation | `invalid captcha prevents submission` | ✓ | | 13 | 1-per-day limit by email | `guest cannot book twice on same day` | ✓ | | 14 | Rate limiting by IP | `rate limiting prevents excessive booking attempts` | ✓ | | 15 | Multi-step submission flow | selectSlot → showConfirm → submit flow verified | ✓ | | 16 | Success page with instructions | `success page is accessible after booking` | ✓ | | 17 | Slot concurrency protection | `slot taken during submission shows error` | ✓ | ### Compliance Check - Coding Standards: ✓ Class-based Volt component with Layout attribute, Flux UI components used, Model::query() pattern followed - Project Structure: ✓ Files in correct locations per story specification - Testing Strategy: ✓ 16 Pest tests with Volt::test() pattern, Mail::fake() and RateLimiter::clear() used properly - All ACs Met: ✓ All 17 acceptance criteria items have corresponding implementation and tests ### Improvements Checklist All items below are advisory recommendations - none are blocking issues: - [x] Rate limiting implemented correctly (5 attempts/24h) - [x] Captcha service encapsulated properly - [x] Transaction with locks for race condition prevention - [x] Email notifications queued properly - [ ] **Consider** adding phone validation regex (currently max:50 only) - low priority enhancement - [ ] **Consider** adding ARIA labels to captcha for accessibility - enhancement for future accessibility audit - [ ] **Consider** logging failed booking attempts for security monitoring - future enhancement ### Security Review **Status: PASS** 1. **Spam Protection**: Math captcha + IP rate limiting provides adequate protection for public form 2. **Race Conditions**: Properly handled with `lockForUpdate()` in transactions 3. **Input Validation**: All inputs validated with appropriate rules 4. **XSS Prevention**: Blade templating with {{ }} escaping 5. **Email Injection**: Using Laravel Mail facade with proper email validation 6. **No sensitive data exposure**: Guest phone/email properly stored, no PII in URLs **Minor Note**: The captcha uses simple addition (1-10 + 1-10). While sufficient for basic spam prevention, sophisticated bots could solve this. For a legal firm's booking system, this is acceptable given the rate limiting backup. ### Performance Considerations **Status: PASS** 1. **Database Queries**: Efficient with proper indexes assumed on `guest_email`, `booking_date`, `booking_time` 2. **Email Sending**: Queued (`implements ShouldQueue`) - no blocking requests 3. **Session Storage**: Captcha stored in session (minimal overhead) 4. **No N+1**: Single consultation insert with direct attribute assignment ### Files Modified During Review No files were modified during this review. Implementation is clean and follows standards. ### Gate Status **Gate: PASS** → `docs/qa/gates/11.2-public-booking-form.yml` ### Recommended Status **✓ Ready for Done** The story implementation is complete, well-tested, and meets all acceptance criteria. The code demonstrates strong security practices with proper race condition handling and spam protection. All 16 tests pass with 52 assertions.