Files
libra/resources/views/livewire/pages/booking.blade.php
T
2026-10-31 18:54:18 +02:00

383 lines
18 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 bool $isLoggedIn = false;
public bool $compact = false;
public function mount(bool $compact = false): void
{
$this->compact = $compact;
// Check if user is logged in - show friendly message instead of booking form
if (auth()->check()) {
$this->isLoggedIn = true;
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();
}
}
}; ?>
@if($compact)
@if($isLoggedIn)
<div class="book-r book-r-note">
<h3>{{ __('booking.logged_in_title') }}</h3>
<p>{{ __('booking.logged_in_message') }}</p>
<a class="btn btn-gold" href="{{ route('client.consultations.book') }}">{{ __('booking.go_to_dashboard') }}</a>
</div>
@elseif($showConfirmation)
<div class="book-r" data-test="compact-confirmation">
<h3>{{ __('booking.confirm_booking') }}</h3>
<p class="note" style="text-align:start">{{ __('booking.confirm_message') }}</p>
<dl class="summary">
<dt>{{ __('booking.guest_name') }}</dt><dd>{{ $guestName }}</dd>
<dt>{{ __('booking.guest_email') }}</dt><dd dir="ltr">{{ $guestEmail }}</dd>
<dt>{{ __('booking.guest_phone') }}</dt><dd dir="ltr">{{ $guestPhone }}</dd>
<dt>{{ __('booking.date') }}</dt><dd>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</dd>
<dt>{{ __('booking.time') }}</dt><dd dir="ltr">{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</dd>
<dt>{{ __('booking.problem_summary') }}</dt><dd>{{ $problemSummary }}</dd>
</dl>
@error('selectedTime')
<p class="err" role="alert">{{ $message }}</p>
@enderror
<button type="button" class="btn btn-gold" wire:click="submit" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
</button>
<button type="button" class="btn btn-ghost" wire:click="$set('showConfirmation', false)">{{ __('common.back') }}</button>
</div>
@else
<form class="book-r" wire:submit="showConfirm" novalidate>
@if(session('success'))
<p class="ok" role="status">{{ session('success') }}</p>
@endif
<div class="f2">
<div class="fld">
<label for="bk-name">{{ __('booking.guest_name') }} *</label>
<input id="bk-name" type="text" wire:model="guestName" autocomplete="name">
@error('guestName') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld">
<label for="bk-phone">{{ __('booking.guest_phone') }} *</label>
<input id="bk-phone" type="tel" dir="ltr" wire:model="guestPhone" autocomplete="tel">
@error('guestPhone') <p class="err">{{ $message }}</p> @enderror
</div>
</div>
<div class="fld">
<label for="bk-email">{{ __('booking.guest_email') }} *</label>
<input id="bk-email" type="email" dir="ltr" wire:model="guestEmail" autocomplete="email">
@error('guestEmail') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld">
<livewire:availability-calendar variant="strip" :picked-date="$selectedDate" :picked-time="$selectedTime" />
@error('selectedTime') <p class="err">{{ $message }}</p> @enderror
@error('selectedDate') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld">
<label for="bk-summary">{{ __('booking.problem_summary') }} *</label>
<textarea id="bk-summary" rows="3" wire:model="problemSummary"></textarea>
<p class="help">{{ __('booking.problem_summary_help') }}</p>
@error('problemSummary') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld cap">
<label for="bk-captcha">{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }} *</label>
<input id="bk-captcha" inputmode="numeric" dir="ltr" wire:model="captchaAnswer" style="max-width:140px">
@error('captchaAnswer') <p class="err">{{ $message }}</p> @enderror
</div>
<button type="submit" class="btn btn-gold" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="showConfirm">{{ __('site.booking_submit') }}</span>
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
</button>
<p class="note">{{ __('booking.limit_message') }}</p>
</form>
@endif
@else
<section class="sec" style="padding-top:56px">
<div class="wrap">
<x-site.section-head tag="h1" :label="__('site.art_booking')" :title="__('booking.request_consultation')" :lede="__('site.booking_lede')" />
<div class="book">
<div class="book-l on-dark">
<div class="contact">
<a href="tel:+970599353502"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/></svg><span dir="ltr">{{ __('footer.phone') }}</span></a>
<a href="mailto:{{ __('footer.email') }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg><span>{{ __('footer.email') }}</span></a>
<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 21s7-6.2 7-12a7 7 0 0 0-14 0c0 5.8 7 12 7 12z"/><circle cx="12" cy="9" r="2.5"/></svg><span>{{ __('footer.address') }}</span></span>
</div>
<x-site.balance mode="static" style="width:190px;height:190px;margin-top:22px" />
</div>
<div class="book-r">
@if($isLoggedIn)
<div class="book-r-note">
<h3>{{ __('booking.logged_in_title') }}</h3>
<p>{{ __('booking.logged_in_message') }}</p>
<a class="btn btn-gold" href="{{ route('client.consultations.book') }}">{{ __('booking.go_to_dashboard') }}</a>
<p class="note">{{ __('booking.or_logout') }}</p>
</div>
@else
@if(session('success'))
<p class="ok" role="status">{{ session('success') }}</p>
@endif
@if(!$selectedDate || !$selectedTime)
<p class="note" style="text-align:start;margin:0 0 14px">{{ __('booking.select_date_time') }}</p>
<livewire:availability-calendar />
@else
<div class="picked">
<div>
<b>{{ __('booking.selected_time') }}</b>
<span>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</span>
<span dir="ltr">{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</span>
</div>
<button type="button" class="btn btn-ghost" wire:click="clearSelection">{{ __('common.change') }}</button>
</div>
@if(!$showConfirmation)
<div class="fld">
<label for="bp-name">{{ __('booking.guest_name') }} *</label>
<input id="bp-name" type="text" wire:model="guestName" autocomplete="name">
@error('guestName') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld">
<label for="bp-email">{{ __('booking.guest_email') }} *</label>
<input id="bp-email" type="email" dir="ltr" wire:model="guestEmail" autocomplete="email">
@error('guestEmail') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld">
<label for="bp-phone">{{ __('booking.guest_phone') }} *</label>
<input id="bp-phone" type="tel" dir="ltr" wire:model="guestPhone" autocomplete="tel">
@error('guestPhone') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld">
<label for="bp-summary">{{ __('booking.problem_summary') }} *</label>
<textarea id="bp-summary" rows="5" wire:model="problemSummary" placeholder="{{ __('booking.problem_summary_placeholder') }}"></textarea>
<p class="help">{{ __('booking.problem_summary_help') }}</p>
@error('problemSummary') <p class="err">{{ $message }}</p> @enderror
</div>
<div class="fld cap">
<label for="bp-captcha">{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }} *</label>
<div class="cap-row">
<input id="bp-captcha" inputmode="numeric" dir="ltr" wire:model="captchaAnswer" style="max-width:140px">
<button type="button" class="btn btn-ghost refresh" wire:click="refreshCaptcha" aria-label="{{ __('site.captcha_refresh') }}">&#8635;</button>
</div>
@error('captchaAnswer') <p class="err">{{ $message }}</p> @enderror
</div>
<button type="button" class="btn btn-gold" wire:click="showConfirm" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
</button>
@else
<h3>{{ __('booking.confirm_booking') }}</h3>
<p class="note" style="text-align:start">{{ __('booking.confirm_message') }}</p>
<dl class="summary">
<dt>{{ __('booking.guest_name') }}</dt><dd>{{ $guestName }}</dd>
<dt>{{ __('booking.guest_email') }}</dt><dd dir="ltr">{{ $guestEmail }}</dd>
<dt>{{ __('booking.guest_phone') }}</dt><dd dir="ltr">{{ $guestPhone }}</dd>
<dt>{{ __('booking.date') }}</dt><dd>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</dd>
<dt>{{ __('booking.time') }}</dt><dd dir="ltr">{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</dd>
<dt>{{ __('booking.duration') }}</dt><dd>45 {{ __('common.minutes') }}</dd>
<dt>{{ __('booking.problem_summary') }}</dt><dd>{{ $problemSummary }}</dd>
</dl>
@error('selectedTime')
<p class="err" role="alert">{{ $message }}</p>
@enderror
<button type="button" class="btn btn-gold" wire:click="submit" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
</button>
<button type="button" class="btn btn-ghost" wire:click="$set('showConfirmation', false)">{{ __('common.back') }}</button>
@endif
@endif
@endif
</div>
</div>
<x-booking-expectations class="mt-10" />
</div>
</section>
@endif