# 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']); }); ``` ## Dependencies - Story 11.1 (Database Schema & Model Updates) ## Definition of Done - [ ] Guest booking form functional at `/booking` - [ ] Logged-in users redirected to client booking - [ ] Availability calendar shows correct slots - [ ] Contact form validates all fields - [ ] Custom captcha prevents automated submissions - [ ] 1-per-day limit enforced by email - [ ] IP rate limiting working - [ ] Success page displays after submission - [ ] All translations in place (Arabic/English) - [ ] Mobile responsive - [ ] All tests pass