libra/docs/stories/story-11.3-guest-notificati...

34 KiB

Story 11.3: Guest Email Notifications & Admin Integration

Epic Reference

Epic 11: Guest Booking

Story Context

This story completes the guest booking workflow by implementing email notifications for guests and updating the admin interface to properly display and manage guest bookings alongside client bookings.

User Story

As a guest who submitted a booking, I want to receive email confirmations about my booking status, So that I know my request was received and can track its progress.

As an admin, I want to see guest contact information when reviewing bookings, So that I can contact them and manage their appointments.

Acceptance Criteria

Guest Email Notifications

  • Guest receives confirmation email when booking submitted
  • Guest receives approval email when booking approved (with date/time details)
  • Guest receives rejection email when booking rejected
  • All emails use existing email template/branding
  • Emails sent to guest_email address
  • Bilingual support based on site locale at submission time

Admin Pending Bookings View

  • Guest bookings appear in pending list alongside client bookings
  • Guest bookings show "Guest" badge/indicator
  • Guest name, email, phone displayed in list
  • Click through to booking review shows full guest details

Admin Booking Review Page

  • Guest contact info displayed prominently
  • Guest name shown instead of user name
  • Guest email shown with mailto link
  • Guest phone shown with tel link
  • Approve/reject workflow works for guest bookings
  • Email notifications sent to guest on status change

Existing Admin Email

  • NewBookingAdminEmail updated to handle guest bookings
  • Admin email shows guest contact info for guest bookings
  • Admin email shows client info for client bookings

Technical Context

Existing Email Architecture

The current codebase uses two approaches for sending emails:

  1. Notifications (app/Notifications/) - Used for client bookings via $user->notify()
  2. Mailables (app/Mail/) - Used for direct email sending via Mail::to()

For guest bookings, we use Mailables (not Notifications) because guests don't have User accounts.

Existing Admin Email Structure

The NewBookingAdminEmail uses locale-specific markdown templates:

  • resources/views/emails/admin/new-booking/en.blade.php
  • resources/views/emails/admin/new-booking/ar.blade.php

Existing Admin Components

  • pending.blade.php uses $booking variable (not $consultation)
  • review.blade.php uses modal-based approve/reject flow (openApproveModal() -> approve())
  • Client notifications use BookingApproved and BookingRejected Notifications

Implementation Steps

Step 1: Create Guest Booking Submitted Email

Create app/Mail/GuestBookingSubmittedMail.php:

<?php

namespace App\Mail;

use App\Models\Consultation;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class GuestBookingSubmittedMail extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        public Consultation $consultation,
        public string $locale = 'en'
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: $this->locale === 'ar'
                ? 'تم استلام طلب الحجز - مكتب ليبرا للمحاماة'
                : 'Booking Request Received - Libra Law Firm',
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.guest.booking-submitted.' . $this->locale,
            with: [
                'consultation' => $this->consultation,
                'guestName' => $this->consultation->guest_name,
                'formattedDate' => $this->getFormattedDate(),
                'formattedTime' => $this->getFormattedTime(),
            ],
        );
    }

    private function getFormattedDate(): string
    {
        $date = $this->consultation->booking_date;
        return $this->locale === 'ar'
            ? $date->format('d/m/Y')
            : $date->format('m/d/Y');
    }

    private function getFormattedTime(): string
    {
        return Carbon::parse($this->consultation->booking_time)->format('h:i A');
    }
}

Step 2: Create Guest Booking Submitted Email Templates

Create resources/views/emails/guest/booking-submitted/en.blade.php:

<x-mail::message>
# Your Booking Request Has Been Received

Dear {{ $guestName }},

Thank you for your consultation request. We have received your booking and our team will review it shortly.

**Requested Date:** {{ $formattedDate }}

**Requested Time:** {{ $formattedTime }}

**Duration:** 45 minutes

You will receive another email once your booking has been reviewed. If approved, you will receive the consultation details and a calendar invitation.

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

Create resources/views/emails/guest/booking-submitted/ar.blade.php:

<x-mail::message>
# تم استلام طلب الحجز الخاص بك

عزيزي/عزيزتي {{ $guestName }}،

شكراً لطلب الاستشارة. لقد تلقينا حجزك وسيقوم فريقنا بمراجعته قريباً.

**التاريخ المطلوب:** {{ $formattedDate }}

**الوقت المطلوب:** {{ $formattedTime }}

**المدة:** 45 دقيقة

ستتلقى رسالة أخرى عند مراجعة حجزك. في حال الموافقة، ستتلقى تفاصيل الاستشارة ودعوة تقويم.

شكراً،<br>
{{ config('app.name') }}
</x-mail::message>

Step 3: Create Guest Booking Approved Email

Create app/Mail/GuestBookingApprovedMail.php:

<?php

namespace App\Mail;

use App\Models\Consultation;
use App\Services\CalendarService;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class GuestBookingApprovedMail extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        public Consultation $consultation,
        public string $locale = 'en',
        public ?string $paymentInstructions = null
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: $this->locale === 'ar'
                ? 'تأكيد الحجز - مكتب ليبرا للمحاماة'
                : 'Booking Confirmed - Libra Law Firm',
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.guest.booking-approved.' . $this->locale,
            with: [
                'consultation' => $this->consultation,
                'guestName' => $this->consultation->guest_name,
                'formattedDate' => $this->getFormattedDate(),
                'formattedTime' => $this->getFormattedTime(),
                'isPaid' => $this->consultation->consultation_type?->value === 'paid',
                'paymentAmount' => $this->consultation->payment_amount,
                'paymentInstructions' => $this->paymentInstructions,
            ],
        );
    }

    public function attachments(): array
    {
        try {
            $calendarService = app(CalendarService::class);
            $icsContent = $calendarService->generateIcs($this->consultation);

            return [
                Attachment::fromData(fn () => $icsContent, 'consultation.ics')
                    ->withMime('text/calendar'),
            ];
        } catch (\Exception $e) {
            Log::error('Failed to generate calendar attachment for guest email', [
                'consultation_id' => $this->consultation->id,
                'error' => $e->getMessage(),
            ]);
            return [];
        }
    }

    private function getFormattedDate(): string
    {
        $date = $this->consultation->booking_date;
        return $this->locale === 'ar'
            ? $date->format('d/m/Y')
            : $date->format('m/d/Y');
    }

    private function getFormattedTime(): string
    {
        return Carbon::parse($this->consultation->booking_time)->format('h:i A');
    }
}

Step 4: Create Guest Booking Approved Email Templates

Create resources/views/emails/guest/booking-approved/en.blade.php:

<x-mail::message>
# Your Booking Has Been Confirmed

Dear {{ $guestName }},

Great news! Your consultation request has been approved.

**Date:** {{ $formattedDate }}

**Time:** {{ $formattedTime }}

**Duration:** 45 minutes

**Type:** {{ $isPaid ? 'Paid Consultation' : 'Free Consultation' }}

@if($isPaid && $paymentAmount)
**Amount Due:** {{ number_format($paymentAmount, 2) }} ILS

@if($paymentInstructions)
**Payment Instructions:**
{{ $paymentInstructions }}
@endif
@endif

A calendar invitation (.ics file) is attached to this email. You can add it to your calendar application.

We look forward to meeting with you.

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

Create resources/views/emails/guest/booking-approved/ar.blade.php:

<x-mail::message>
# تم تأكيد حجزك

عزيزي/عزيزتي {{ $guestName }}،

أخبار سارة! تمت الموافقة على طلب الاستشارة الخاص بك.

**التاريخ:** {{ $formattedDate }}

**الوقت:** {{ $formattedTime }}

**المدة:** 45 دقيقة

**النوع:** {{ $isPaid ? 'استشارة مدفوعة' : 'استشارة مجانية' }}

@if($isPaid && $paymentAmount)
**المبلغ المستحق:** {{ number_format($paymentAmount, 2) }} شيكل

@if($paymentInstructions)
**تعليمات الدفع:**
{{ $paymentInstructions }}
@endif
@endif

مرفق بهذه الرسالة ملف دعوة تقويم (.ics). يمكنك إضافته إلى تطبيق التقويم الخاص بك.

نتطلع للقائك.

شكراً،<br>
{{ config('app.name') }}
</x-mail::message>

Step 5: Create Guest Booking Rejected Email

Create app/Mail/GuestBookingRejectedMail.php:

<?php

namespace App\Mail;

use App\Models\Consultation;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class GuestBookingRejectedMail extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(
        public Consultation $consultation,
        public string $locale = 'en',
        public ?string $reason = null
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: $this->locale === 'ar'
                ? 'تحديث الحجز - مكتب ليبرا للمحاماة'
                : 'Booking Update - Libra Law Firm',
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'emails.guest.booking-rejected.' . $this->locale,
            with: [
                'consultation' => $this->consultation,
                'guestName' => $this->consultation->guest_name,
                'formattedDate' => $this->getFormattedDate(),
                'formattedTime' => $this->getFormattedTime(),
                'reason' => $this->reason,
                'hasReason' => !empty($this->reason),
            ],
        );
    }

    private function getFormattedDate(): string
    {
        $date = $this->consultation->booking_date;
        return $this->locale === 'ar'
            ? $date->format('d/m/Y')
            : $date->format('m/d/Y');
    }

    private function getFormattedTime(): string
    {
        return Carbon::parse($this->consultation->booking_time)->format('h:i A');
    }
}

Step 6: Create Guest Booking Rejected Email Templates

Create resources/views/emails/guest/booking-rejected/en.blade.php:

<x-mail::message>
# Booking Update

Dear {{ $guestName }},

We regret to inform you that we are unable to accommodate your consultation request for the following time:

**Requested Date:** {{ $formattedDate }}

**Requested Time:** {{ $formattedTime }}

@if($hasReason)
**Reason:** {{ $reason }}
@endif

We apologize for any inconvenience. Please feel free to submit a new booking request for a different time slot.

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

Create resources/views/emails/guest/booking-rejected/ar.blade.php:

<x-mail::message>
# تحديث الحجز

عزيزي/عزيزتي {{ $guestName }}،

نأسف لإبلاغك بأننا غير قادرين على استيعاب طلب الاستشارة الخاص بك في الوقت التالي:

**التاريخ المطلوب:** {{ $formattedDate }}

**الوقت المطلوب:** {{ $formattedTime }}

@if($hasReason)
**السبب:** {{ $reason }}
@endif

نعتذر عن أي إزعاج. لا تتردد في تقديم طلب حجز جديد لفترة زمنية مختلفة.

شكراً،<br>
{{ config('app.name') }}
</x-mail::message>

Step 7: Update NewBookingAdminEmail

Update app/Mail/NewBookingAdminEmail.php to handle guest bookings.

Modify the content() method to use the Consultation model's helper methods:

public function content(): Content
{
    $admin = $this->getAdminUser();
    $locale = $admin?->preferred_language ?? 'en';

    return new Content(
        markdown: 'emails.admin.new-booking.'.$locale,
        with: [
            'consultation' => $this->consultation,
            'client' => $this->consultation->user,
            'clientName' => $this->consultation->getClientName(),
            'clientEmail' => $this->consultation->getClientEmail(),
            'clientPhone' => $this->consultation->getClientPhone(),
            'isGuest' => $this->consultation->isGuest(),
            'formattedDate' => $this->getFormattedDate($locale),
            'formattedTime' => $this->getFormattedTime(),
            'reviewUrl' => $this->getReviewUrl(),
        ],
    );
}

Step 8: Update Admin New Booking Email Templates

Update resources/views/emails/admin/new-booking/en.blade.php to show guest indicator:

Add after the title section:

@if($isGuest)
**Booking Type:** Guest (No Account)

@endif
**Client Name:** {{ $clientName }}

**Client Email:** {{ $clientEmail }}

**Client Phone:** {{ $clientPhone ?? 'Not provided' }}

Update resources/views/emails/admin/new-booking/ar.blade.php similarly:

@if($isGuest)
**نوع الحجز:** زائر (بدون حساب)

@endif
**اسم العميل:** {{ $clientName }}

**بريد العميل:** {{ $clientEmail }}

**هاتف العميل:** {{ $clientPhone ?? 'غير متوفر' }}

Step 9: Update Admin Pending Bookings List

Update resources/views/livewire/admin/bookings/pending.blade.php.

In the booking info section (around line 194), replace the client name display:

<!-- Booking Info -->
<div class="flex-1">
    <div class="flex items-center gap-3 mb-2">
        @if($booking->isGuest())
            <flux:badge color="amber" size="sm">{{ __('admin.guest') }}</flux:badge>
        @endif
        <span class="font-semibold text-zinc-900 dark:text-zinc-100">
            {{ $booking->getClientName() }}
        </span>
        <flux:badge variant="warning" size="sm">
            {{ $booking->status->label() }}
        </flux:badge>
    </div>

    <div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-zinc-600 dark:text-zinc-400">
        <div class="flex items-center gap-2">
            <flux:icon name="calendar" class="w-4 h-4" />
            {{ \Carbon\Carbon::parse($booking->booking_date)->translatedFormat('l, d M Y') }}
        </div>
        <div class="flex items-center gap-2">
            <flux:icon name="clock" class="w-4 h-4" />
            {{ \Carbon\Carbon::parse($booking->booking_time)->format('g:i A') }}
        </div>
        <div class="flex items-center gap-2">
            <flux:icon name="envelope" class="w-4 h-4" />
            <a href="mailto:{{ $booking->getClientEmail() }}" class="hover:underline">
                {{ $booking->getClientEmail() }}
            </a>
        </div>
        <div class="flex items-center gap-2">
            <flux:icon name="document-text" class="w-4 h-4" />
            {{ __('admin.submitted') }}: {{ $booking->created_at->translatedFormat('d M Y') }}
        </div>
    </div>

    <p class="mt-3 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">
        {{ Str::limit($booking->problem_summary, 150) }}
    </p>
</div>

Also update the quickApprove and quickReject methods to handle guest bookings:

public function quickApprove(int $id): void
{
    $consultation = Consultation::with('user')->findOrFail($id);

    // ... existing validation and update logic ...

    // Send appropriate notification/email based on guest/client
    if ($consultation->isGuest()) {
        Mail::to($consultation->guest_email)->queue(
            new \App\Mail\GuestBookingApprovedMail(
                $consultation,
                app()->getLocale()
            )
        );
    } elseif ($consultation->user) {
        $consultation->user->notify(
            new BookingApproved($consultation, $icsContent ?? '', null)
        );
    }

    // ... rest of logging ...
}

public function quickReject(int $id): void
{
    $consultation = Consultation::with('user')->findOrFail($id);

    // ... existing validation and update logic ...

    // Send appropriate notification/email based on guest/client
    if ($consultation->isGuest()) {
        Mail::to($consultation->guest_email)->queue(
            new \App\Mail\GuestBookingRejectedMail(
                $consultation,
                app()->getLocale(),
                null
            )
        );
    } elseif ($consultation->user) {
        $consultation->user->notify(
            new BookingRejected($consultation, null)
        );
    }

    // ... rest of logging ...
}

Add the required imports at the top:

use App\Mail\GuestBookingApprovedMail;
use App\Mail\GuestBookingRejectedMail;
use Illuminate\Support\Facades\Mail;

Step 10: Update Admin Booking Review Page

Update resources/views/livewire/admin/bookings/review.blade.php.

Replace the "Client Information" section (around line 188-218):

<!-- Client/Guest Information -->
<div class="bg-white dark:bg-zinc-800 rounded-lg p-6 border border-zinc-200 dark:border-zinc-700 mb-6">
    <div class="flex items-center gap-2 mb-4">
        <flux:heading size="lg">{{ __('admin.client_information') }}</flux:heading>
        @if($consultation->isGuest())
            <flux:badge color="amber" size="sm">{{ __('admin.guest') }}</flux:badge>
        @endif
    </div>

    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
        <div>
            <p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('admin.client_name') }}</p>
            <p class="font-medium text-zinc-900 dark:text-zinc-100">
                {{ $consultation->getClientName() }}
            </p>
        </div>
        <div>
            <p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('admin.client_email') }}</p>
            <p class="font-medium text-zinc-900 dark:text-zinc-100">
                <a href="mailto:{{ $consultation->getClientEmail() }}" class="text-primary hover:underline">
                    {{ $consultation->getClientEmail() }}
                </a>
            </p>
        </div>
        <div>
            <p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('admin.client_phone') }}</p>
            <p class="font-medium text-zinc-900 dark:text-zinc-100">
                @if($consultation->getClientPhone())
                    <a href="tel:{{ $consultation->getClientPhone() }}" class="text-primary hover:underline">
                        {{ $consultation->getClientPhone() }}
                    </a>
                @else
                    -
                @endif
            </p>
        </div>
        @unless($consultation->isGuest())
        <div>
            <p class="text-sm text-zinc-500 dark:text-zinc-400">{{ __('admin.client_type') }}</p>
            <p class="font-medium text-zinc-900 dark:text-zinc-100">
                {{ $consultation->user?->user_type?->label() ?? '-' }}
            </p>
        </div>
        @endunless
    </div>
</div>

Update the approve() method in the PHP section:

public function approve(): void
{
    if ($this->consultation->status !== ConsultationStatus::Pending) {
        session()->flash('error', __('admin.booking_already_processed'));
        $this->showApproveModal = false;
        return;
    }

    $this->validate([
        'consultationType' => ['required', 'in:free,paid'],
        'paymentAmount' => ['required_if:consultationType,paid', 'nullable', 'numeric', 'min:0'],
        'paymentInstructions' => ['nullable', 'string', 'max:1000'],
    ]);

    $oldStatus = $this->consultation->status->value;
    $type = $this->consultationType === 'paid' ? ConsultationType::Paid : ConsultationType::Free;

    $this->consultation->update([
        'status' => ConsultationStatus::Approved,
        'consultation_type' => $type,
        'payment_amount' => $type === ConsultationType::Paid ? $this->paymentAmount : null,
        'payment_status' => $type === ConsultationType::Paid ? PaymentStatus::Pending : PaymentStatus::NotApplicable,
    ]);

    // Generate calendar file
    $icsContent = null;
    try {
        $calendarService = app(CalendarService::class);
        $icsContent = $calendarService->generateIcs($this->consultation);
    } catch (\Exception $e) {
        Log::error('Failed to generate calendar file', [
            'consultation_id' => $this->consultation->id,
            'error' => $e->getMessage(),
        ]);
    }

    // Send appropriate notification/email based on guest/client
    if ($this->consultation->isGuest()) {
        Mail::to($this->consultation->guest_email)->queue(
            new GuestBookingApprovedMail(
                $this->consultation,
                app()->getLocale(),
                $this->paymentInstructions ?: null
            )
        );
    } elseif ($this->consultation->user) {
        $this->consultation->user->notify(
            new BookingApproved(
                $this->consultation,
                $icsContent ?? '',
                $this->paymentInstructions ?: null
            )
        );
    }

    // Log action
    AdminLog::create([
        'admin_id' => auth()->id(),
        'action' => 'approve',
        'target_type' => 'consultation',
        'target_id' => $this->consultation->id,
        'old_values' => ['status' => $oldStatus],
        'new_values' => [
            'status' => ConsultationStatus::Approved->value,
            'consultation_type' => $type->value,
            'payment_amount' => $this->paymentAmount,
        ],
        'ip_address' => request()->ip(),
        'created_at' => now(),
    ]);

    session()->flash('success', __('admin.booking_approved'));
    $this->redirect(route('admin.bookings.pending'), navigate: true);
}

Update the reject() method similarly:

public function reject(): void
{
    if ($this->consultation->status !== ConsultationStatus::Pending) {
        session()->flash('error', __('admin.booking_already_processed'));
        $this->showRejectModal = false;
        return;
    }

    $this->validate([
        'rejectionReason' => ['nullable', 'string', 'max:1000'],
    ]);

    $oldStatus = $this->consultation->status->value;

    $this->consultation->update([
        'status' => ConsultationStatus::Rejected,
    ]);

    // Send appropriate notification/email based on guest/client
    if ($this->consultation->isGuest()) {
        Mail::to($this->consultation->guest_email)->queue(
            new GuestBookingRejectedMail(
                $this->consultation,
                app()->getLocale(),
                $this->rejectionReason ?: null
            )
        );
    } elseif ($this->consultation->user) {
        $this->consultation->user->notify(
            new BookingRejected($this->consultation, $this->rejectionReason ?: null)
        );
    }

    // Log action
    AdminLog::create([
        'admin_id' => auth()->id(),
        'action' => 'reject',
        'target_type' => 'consultation',
        'target_id' => $this->consultation->id,
        'old_values' => ['status' => $oldStatus],
        'new_values' => [
            'status' => ConsultationStatus::Rejected->value,
            'reason' => $this->rejectionReason,
        ],
        'ip_address' => request()->ip(),
        'created_at' => now(),
    ]);

    session()->flash('success', __('admin.booking_rejected'));
    $this->redirect(route('admin.bookings.pending'), navigate: true);
}

Add required imports at the top of the PHP section:

use App\Mail\GuestBookingApprovedMail;
use App\Mail\GuestBookingRejectedMail;
use Illuminate\Support\Facades\Mail;

Update the with() method to handle guest consultations (they have no history):

public function with(): array
{
    // Guest bookings don't have consultation history
    if ($this->consultation->isGuest()) {
        return ['consultationHistory' => collect()];
    }

    return [
        'consultationHistory' => Consultation::query()
            ->where('user_id', $this->consultation->user_id)
            ->where('id', '!=', $this->consultation->id)
            ->orderBy('booking_date', 'desc')
            ->limit(5)
            ->get(),
    ];
}

Update the modal summary sections to use helper methods:

In the Approve Modal (around line 314):

<div class="bg-zinc-50 dark:bg-zinc-700 p-4 rounded-lg">
    <p><strong>{{ __('admin.client') }}:</strong> {{ $consultation->getClientName() }}</p>
    <p><strong>{{ __('admin.date') }}:</strong> {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}</p>
    <p><strong>{{ __('admin.time') }}:</strong> {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}</p>
</div>

In the Reject Modal (around line 369):

<div class="bg-zinc-50 dark:bg-zinc-700 p-4 rounded-lg">
    <p><strong>{{ __('admin.client') }}:</strong> {{ $consultation->getClientName() }}</p>
    <p><strong>{{ __('admin.date') }}:</strong> {{ \Carbon\Carbon::parse($consultation->booking_date)->translatedFormat('l, d M Y') }}</p>
    <p><strong>{{ __('admin.time') }}:</strong> {{ \Carbon\Carbon::parse($consultation->booking_time)->format('g:i A') }}</p>
</div>

Step 11: Add Translation Keys

Add to lang/en/admin.php:

'guest' => 'Guest',
'client_information' => 'Client Information',
'client_type' => 'Client Type',

Add to lang/ar/admin.php:

'guest' => 'زائر',
'client_information' => 'معلومات العميل',
'client_type' => 'نوع العميل',

Testing Requirements

Email Tests

Create tests/Feature/GuestEmailNotificationTest.php:

<?php

use App\Enums\ConsultationStatus;
use App\Mail\GuestBookingApprovedMail;
use App\Mail\GuestBookingRejectedMail;
use App\Mail\GuestBookingSubmittedMail;
use App\Mail\NewBookingAdminEmail;
use App\Models\Consultation;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

test('guest receives confirmation email on booking submission', function () {
    Mail::fake();

    $consultation = Consultation::factory()->guest()->create([
        'status' => ConsultationStatus::Pending,
    ]);

    Mail::to($consultation->guest_email)->send(
        new GuestBookingSubmittedMail($consultation, 'en')
    );

    Mail::assertSent(GuestBookingSubmittedMail::class, function ($mail) use ($consultation) {
        return $mail->hasTo($consultation->guest_email);
    });
});

test('guest receives approval email with calendar attachment', function () {
    Mail::fake();

    $consultation = Consultation::factory()->guest()->create([
        'status' => ConsultationStatus::Approved,
    ]);

    Mail::to($consultation->guest_email)->send(
        new GuestBookingApprovedMail($consultation, 'en')
    );

    Mail::assertSent(GuestBookingApprovedMail::class, function ($mail) {
        return count($mail->attachments()) === 1;
    });
});

test('guest receives rejection email', function () {
    Mail::fake();

    $consultation = Consultation::factory()->guest()->create([
        'status' => ConsultationStatus::Rejected,
    ]);

    Mail::to($consultation->guest_email)->send(
        new GuestBookingRejectedMail($consultation, 'en', 'Not available')
    );

    Mail::assertSent(GuestBookingRejectedMail::class, function ($mail) use ($consultation) {
        return $mail->hasTo($consultation->guest_email);
    });
});

test('admin email shows guest indicator for guest bookings', function () {
    Mail::fake();

    $consultation = Consultation::factory()->guest()->create();
    $admin = User::factory()->admin()->create();

    Mail::to($admin)->send(new NewBookingAdminEmail($consultation));

    Mail::assertSent(NewBookingAdminEmail::class);
});

Admin Interface Tests

Create tests/Feature/Admin/GuestBookingManagementTest.php:

<?php

use App\Enums\ConsultationStatus;
use App\Mail\GuestBookingApprovedMail;
use App\Mail\GuestBookingRejectedMail;
use App\Models\Consultation;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt;

test('admin can see guest bookings in pending list', function () {
    $admin = User::factory()->admin()->create();
    $guestConsultation = Consultation::factory()->guest()->pending()->create();

    $this->actingAs($admin);

    Volt::test('admin.bookings.pending')
        ->assertSee($guestConsultation->guest_name)
        ->assertSee(__('admin.guest'));
});

test('admin can view guest booking details in review page', function () {
    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->guest()->pending()->create([
        'guest_name' => 'Test Guest',
        'guest_email' => 'test@example.com',
    ]);

    $this->actingAs($admin);

    Volt::test('admin.bookings.review', ['consultation' => $consultation])
        ->assertSee('Test Guest')
        ->assertSee('test@example.com')
        ->assertSee(__('admin.guest'));
});

test('admin can approve guest booking via modal', function () {
    Mail::fake();

    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->guest()->pending()->create();

    $this->actingAs($admin);

    Volt::test('admin.bookings.review', ['consultation' => $consultation])
        ->call('openApproveModal')
        ->assertSet('showApproveModal', true)
        ->set('consultationType', 'free')
        ->call('approve');

    expect($consultation->fresh()->status)->toBe(ConsultationStatus::Approved);
    Mail::assertQueued(GuestBookingApprovedMail::class);
});

test('admin can reject guest booking via modal', function () {
    Mail::fake();

    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->guest()->pending()->create();

    $this->actingAs($admin);

    Volt::test('admin.bookings.review', ['consultation' => $consultation])
        ->call('openRejectModal')
        ->assertSet('showRejectModal', true)
        ->set('rejectionReason', 'Not available for this time')
        ->call('reject');

    expect($consultation->fresh()->status)->toBe(ConsultationStatus::Rejected);
    Mail::assertQueued(GuestBookingRejectedMail::class);
});

test('admin can quick approve guest booking from pending list', function () {
    Mail::fake();

    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->guest()->pending()->create();

    $this->actingAs($admin);

    Volt::test('admin.bookings.pending')
        ->call('quickApprove', $consultation->id);

    expect($consultation->fresh()->status)->toBe(ConsultationStatus::Approved);
    Mail::assertQueued(GuestBookingApprovedMail::class);
});

test('admin can quick reject guest booking from pending list', function () {
    Mail::fake();

    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->guest()->pending()->create();

    $this->actingAs($admin);

    Volt::test('admin.bookings.pending')
        ->call('quickReject', $consultation->id);

    expect($consultation->fresh()->status)->toBe(ConsultationStatus::Rejected);
    Mail::assertQueued(GuestBookingRejectedMail::class);
});

test('guest booking review shows no consultation history', function () {
    $admin = User::factory()->admin()->create();
    $consultation = Consultation::factory()->guest()->pending()->create();

    $this->actingAs($admin);

    Volt::test('admin.bookings.review', ['consultation' => $consultation])
        ->assertDontSee(__('admin.consultation_history'));
});

test('client booking still uses notification system', function () {
    Notification::fake();

    $admin = User::factory()->admin()->create();
    $client = User::factory()->client()->create();
    $consultation = Consultation::factory()->pending()->create(['user_id' => $client->id]);

    $this->actingAs($admin);

    Volt::test('admin.bookings.review', ['consultation' => $consultation])
        ->call('openApproveModal')
        ->set('consultationType', 'free')
        ->call('approve');

    Notification::assertSentTo($client, BookingApproved::class);
});

Dependencies

  • Story 11.1 (Database Schema & Model Updates) - provides isGuest(), getClientName(), getClientEmail(), getClientPhone() methods
  • Story 11.2 (Public Booking Form) - uses the guest email classes created in this story

Definition of Done

  • Guest booking submitted email created and working (en/ar templates)
  • Guest booking approved email created with calendar attachment (en/ar templates)
  • Guest booking rejected email created (en/ar templates)
  • Admin new booking email updated for guests (shows guest indicator)
  • Admin pending bookings shows guest badge and uses helper methods
  • Admin booking review shows guest contact info with mailto/tel links
  • Quick approve/reject sends correct email (guest Mail vs client Notification)
  • Modal approve/reject sends correct email (guest Mail vs client Notification)
  • All translations in place (Arabic/English)
  • All email tests pass
  • All admin interface tests pass
  • Existing client booking notifications unchanged