libra/resources/views/livewire/pages/booking.blade.php

337 lines
13 KiB
PHP

<?php
use App\Enums\ConsultationStatus;
use App\Enums\PaymentStatus;
use App\Mail\GuestBookingSubmittedMail;
use App\Mail\NewBookingAdminEmail;
use App\Models\Consultation;
use App\Models\User;
use App\Services\AvailabilityService;
use App\Services\CaptchaService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.public')] class extends Component
{
public ?string $selectedDate = null;
public ?string $selectedTime = null;
public string $guestName = '';
public string $guestEmail = '';
public string $guestPhone = '';
public string $problemSummary = '';
public string $captchaAnswer = '';
public array $captchaQuestion = [];
public bool $showConfirmation = false;
public function mount(): void
{
// Redirect logged-in users to client booking
if (auth()->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();
}
}
}; ?>
<div class="max-w-4xl mx-auto py-8 px-4">
<flux:heading size="xl" class="mb-6 text-body">
{{ __('booking.request_consultation') }}
</flux:heading>
@if(session('success'))
<flux:callout variant="success" class="mb-6">
{{ session('success') }}
</flux:callout>
@endif
@if(!$selectedDate || !$selectedTime)
{{-- Step 1: Calendar Selection --}}
<flux:callout class="mb-6">
<p class="text-body">{{ __('booking.guest_intro') }}</p>
</flux:callout>
<p class="mb-4 text-zinc-600 dark:text-zinc-400">
{{ __('booking.select_date_time') }}
</p>
<livewire:availability-calendar />
@else
{{-- Step 2+: Contact Form & Confirmation --}}
<div class="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg mb-6 border border-amber-200 dark:border-amber-800">
<div class="flex justify-between items-center">
<div>
<p class="font-semibold text-zinc-900 dark:text-zinc-100">
{{ __('booking.selected_time') }}
</p>
<p class="text-zinc-600 dark:text-zinc-400">
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}
</p>
<p class="text-zinc-600 dark:text-zinc-400">
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}
</p>
</div>
<flux:button size="sm" wire:click="clearSelection">
{{ __('common.change') }}
</flux:button>
</div>
</div>
@if(!$showConfirmation)
{{-- Contact Form --}}
<div class="space-y-4">
<flux:field>
<flux:label class="required">{{ __('booking.guest_name') }}</flux:label>
<flux:input wire:model="guestName" type="text" />
<flux:error name="guestName" />
</flux:field>
<flux:field>
<flux:label class="required">{{ __('booking.guest_email') }}</flux:label>
<flux:input wire:model="guestEmail" type="email" />
<flux:error name="guestEmail" />
</flux:field>
<flux:field>
<flux:label class="required">{{ __('booking.guest_phone') }}</flux:label>
<flux:input wire:model="guestPhone" type="tel" />
<flux:error name="guestPhone" />
</flux:field>
<flux:field>
<flux:label class="required">{{ __('booking.problem_summary') }}</flux:label>
<flux:textarea
wire:model="problemSummary"
rows="6"
placeholder="{{ __('booking.problem_summary_placeholder') }}"
/>
<flux:description>{{ __('booking.problem_summary_help') }}</flux:description>
<flux:error name="problemSummary" />
</flux:field>
{{-- Custom Captcha --}}
<flux:field>
<flux:label class="required">
{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }}
</flux:label>
<div class="flex gap-2">
<flux:input wire:model="captchaAnswer" type="text" class="w-32" />
<flux:button size="sm" wire:click="refreshCaptcha" type="button">
<flux:icon name="arrow-path" class="w-4 h-4" />
</flux:button>
</div>
<flux:error name="captchaAnswer" />
</flux:field>
<flux:button
wire:click="showConfirm"
class="w-full sm:w-auto min-h-[44px]"
wire:loading.attr="disabled"
>
<span wire:loading.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
</flux:button>
</div>
@else
{{-- Confirmation Step --}}
<flux:callout>
<flux:heading size="sm" class="text-body">{{ __('booking.confirm_booking') }}</flux:heading>
<p class="text-zinc-600 dark:text-zinc-400">{{ __('booking.confirm_message') }}</p>
<div class="mt-4 space-y-2">
<p><strong>{{ __('booking.guest_name') }}:</strong> {{ $guestName }}</p>
<p><strong>{{ __('booking.guest_email') }}:</strong> {{ $guestEmail }}</p>
<p><strong>{{ __('booking.guest_phone') }}:</strong> {{ $guestPhone }}</p>
<p><strong>{{ __('booking.date') }}:</strong>
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
<p><strong>{{ __('booking.time') }}:</strong>
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
<p><strong>{{ __('booking.duration') }}:</strong> 45 {{ __('common.minutes') }}</p>
</div>
<div class="mt-4">
<p><strong>{{ __('booking.problem_summary') }}:</strong></p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ $problemSummary }}</p>
</div>
</flux:callout>
<div class="flex flex-col sm:flex-row gap-3 mt-4">
<flux:button wire:click="$set('showConfirmation', false)" class="w-full sm:w-auto min-h-[44px]">
{{ __('common.back') }}
</flux:button>
<flux:button
wire:click="submit"
variant="primary"
wire:loading.attr="disabled"
class="w-full sm:w-auto min-h-[44px]"
>
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
</flux:button>
</div>
@error('selectedTime')
<flux:callout variant="danger" class="mt-4">
{{ $message }}
</flux:callout>
@enderror
@endif
@endif
</div>