24-5: home booking card (strip variant, compact booking) — implemented, QA Review; footer brand layout fix from 24-2
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: 24-5
|
||||
title: 24-5 Home Booking Card (Compact Booking and Day Strip)
|
||||
status: Ready for Dev
|
||||
status: QA Review
|
||||
date: 2026-10-31
|
||||
parent: 24
|
||||
---
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Manual tests — 24-5 Home Booking Card
|
||||
|
||||
Automated (passed 2026-10-31, `tests/Feature/Design/HomeBookingCardTest.php`, 11 tests): strip variant lists ≤14 bookable days,
|
||||
selects the first and loads its slots, other days load theirs; none-message when nothing is bookable; the default `month`
|
||||
variant is unchanged; compact form order (name, phone, email, day strip, slots, summary, captcha, submit, note); a guest booking
|
||||
through the compact card creates one pending guest consultation and redirects to `/booking/success`; short summary, wrong
|
||||
captcha, missing slot and a second booking for the same email/day each error and create nothing; logged-in client sees the
|
||||
logged-in message and no form; `/` has `#book` with the finale balance; `/booking` is not compact; all keys in ar and en.
|
||||
Existing suites run: `tests/Feature/Client`, `GuestBookingTest` (one pre-existing stale-copy failure: "guest booking page shows calendar").
|
||||
UI loop (libra.test `/`): no horizontal scroll at 390/1440; the day strip scrolls inside the card (intended). After a
|
||||
grid fix the 1440 render shows the form fully inside the cream panel.
|
||||
|
||||
- [ ] `/` bottom: forest card with title, text, phone, email, address and the balance on one side, cream form on the other.
|
||||
- [ ] Click another day in the strip: its times appear; click a time: it turns gold.
|
||||
- [ ] Complete a real booking from the card on a test day (fill all fields, captcha, Request, Confirm): lands on success page, email arrives. (Creates a real pending consultation — cancel it after.)
|
||||
- [ ] Try a short summary, a wrong captcha answer, and no time: each shows an error, nothing is booked.
|
||||
- [ ] Log in as a client and open `/`: the card shows the logged-in message and a link to the dashboard booking page.
|
||||
- [ ] `/booking` and the client dashboard booking page still show the month calendar.
|
||||
- [ ] Balance in the card: tipped at the top of the page, level when you scroll to the end.
|
||||
- [ ] 390px: card is one column, day strip scrolls sideways inside the card, page does not.
|
||||
@@ -141,7 +141,7 @@
|
||||
| 24-2 | 24-2 Navigation and Footer | QA Review |
|
||||
| 24-3 | 24-3 Signature Balance Component | QA Review |
|
||||
| 24-4 | 24-4 Home Page Sections (Hero to Latest Articles) | QA Review |
|
||||
| 24-5 | 24-5 Home Booking Card (Compact Booking and Day Strip) | Ready for Dev |
|
||||
| 24-5 | 24-5 Home Booking Card (Compact Booking and Day Strip) | QA Review |
|
||||
| 24-6 | 24-6 Booking Page and Booking Success | Ready for Dev |
|
||||
| 24-7 | 24-7 About Page | Ready for Dev |
|
||||
| 24-8 | 24-8 Articles and Legal Pages | Ready for Dev |
|
||||
|
||||
@@ -64,4 +64,11 @@ return [
|
||||
'card_diaspora_text' => 'نساعد الفلسطينيين في الخارج على البحث في عائلاتهم وتاريخهم وأراضيهم.',
|
||||
'art_values' => 'المادة ٠٤ — القيم',
|
||||
'art_articles' => 'المادة ٠٥ — المقالات',
|
||||
'art_booking' => 'المادة ٠٦ — الاستشارة',
|
||||
'booking_title' => 'احجز استشارة',
|
||||
'booking_lede' => 'اختر يوماً ووقتاً متاحَين، وأخبرنا باختصار عن قضيتك. نراجع كل طلب ونؤكّد الموعد بالبريد الإلكتروني — دون الحاجة إلى حساب.',
|
||||
'booking_day' => 'اليوم',
|
||||
'booking_time' => 'الوقت',
|
||||
'booking_submit' => 'اطلب هذا الموعد',
|
||||
'booking_none' => 'لا توجد مواعيد متاحة حالياً',
|
||||
];
|
||||
|
||||
@@ -64,4 +64,11 @@ return [
|
||||
'card_diaspora_text' => 'Helping Palestinians abroad research their families, their history and their land.',
|
||||
'art_values' => 'Article 04 — Values',
|
||||
'art_articles' => 'Article 05 — Articles',
|
||||
'art_booking' => 'Article 06 — Consultation',
|
||||
'booking_title' => 'Book a consultation',
|
||||
'booking_lede' => 'Choose an available day and time and tell us briefly about your case. We review every request and confirm by email — no account needed.',
|
||||
'booking_day' => 'Day',
|
||||
'booking_time' => 'Time',
|
||||
'booking_submit' => 'Request this appointment',
|
||||
'booking_none' => 'No times are available at the moment',
|
||||
];
|
||||
|
||||
@@ -112,6 +112,8 @@ body.site .foot .brand small{color:var(--gold-lt)}
|
||||
body.site .foot p{margin:16px 0 0;max-width:26em}
|
||||
body.site .site-footer .legal{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;border-top:1px solid rgba(244,241,234,.1);margin-top:48px;padding-top:22px;font-size:.8rem;color:rgba(196,168,130,.75)}
|
||||
body.site .foot span{display:block;padding:10px 0}
|
||||
body.site .foot a.brand{display:flex;padding:0}
|
||||
body.site .foot .brand span{display:block;padding:0}
|
||||
@media (max-width:1100px){
|
||||
body.site .links{display:none}
|
||||
body.site .menu-btn{display:grid;place-items:center;margin-inline-start:auto}
|
||||
@@ -262,6 +264,56 @@ body.site .post.text .more{color:var(--gold-lt)}
|
||||
body.site .trust{gap:18px}
|
||||
}
|
||||
|
||||
/* ---------- Booking card (24-5) ---------- */
|
||||
body.site .book{background:var(--forest);color:var(--cream);border-radius:28px;overflow:hidden;display:grid;grid-template-columns:1fr 1.1fr;
|
||||
box-shadow:0 50px 90px -40px rgba(30,34,25,.8);position:relative}
|
||||
body.site .book-l{padding:56px 52px;position:relative;background:radial-gradient(500px 300px at 20% 0%,rgba(196,168,130,.18),transparent 70%)}
|
||||
body.site .book-l h2{color:var(--cream);font-size:clamp(2rem,3.2vw,2.8rem);margin:14px 0 18px}
|
||||
body.site .book-l p{color:var(--gold-lt);margin:0 0 26px}
|
||||
body.site .book-l .eyebrow{color:var(--gold-lt)}
|
||||
body.site .contact{display:grid;gap:12px;font-size:.95rem}
|
||||
body.site .contact a,body.site .contact span{display:flex;gap:12px;align-items:center;text-decoration:none;color:var(--cream)}
|
||||
body.site .contact svg{width:20px;height:20px;color:var(--gold);flex:none}
|
||||
body.site .book-r{background:var(--cream);color:var(--ink);padding:44px 44px 40px;margin:12px;border-radius:20px}
|
||||
body.site .f2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
body.site .book-r label{display:block;font-size:.85rem;font-weight:600;margin-bottom:6px}
|
||||
body.site .book-r input,body.site .book-r textarea{width:100%;min-height:48px;padding:12px 16px;border-radius:12px;border:1.5px solid rgba(45,54,36,.18);background:#fff;font:400 1rem var(--body);color:var(--ink);transition:border-color .2s, box-shadow .2s}
|
||||
body.site .book-r input:focus,body.site .book-r textarea:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 4px rgba(166,137,102,.2)}
|
||||
body.site .fld{margin-bottom:14px}
|
||||
body.site .days{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px;scrollbar-width:none}
|
||||
body.site .day{flex:none;width:66px;min-height:72px;border-radius:14px;border:1.5px solid rgba(45,54,36,.15);background:#fff;cursor:pointer;font:500 .78rem/1.3 var(--body);color:var(--muted);transition:all .25s var(--ease)}
|
||||
body.site .day b{display:block;font:700 1.35rem/1.2 var(--display);color:var(--ink)}
|
||||
body.site .day:hover{border-color:var(--gold);transform:translateY(-2px)}
|
||||
body.site .day[aria-pressed=true]{background:var(--forest);border-color:var(--forest);color:var(--gold-lt);box-shadow:0 10px 22px -10px rgba(45,54,36,.8)}
|
||||
body.site .day[aria-pressed=true] b{color:var(--cream)}
|
||||
body.site .slots{display:flex;flex-wrap:wrap;gap:8px}
|
||||
body.site .slot{min-height:44px;padding:8px 16px;border-radius:999px;border:1.5px solid rgba(45,54,36,.15);background:#fff;cursor:pointer;font:500 .9rem var(--body);transition:all .2s}
|
||||
body.site .slot:hover{border-color:var(--gold)}
|
||||
body.site .slot[aria-pressed=true]{background:var(--gold);border-color:var(--gold);color:var(--ink)}
|
||||
body.site .book-r .btn{width:100%;justify-content:center;margin-top:8px}
|
||||
body.site .note{font-size:.8rem;color:var(--muted);text-align:center;margin:12px 0 0}
|
||||
body.site .book>*{min-width:0}
|
||||
body.site .book-l .site-balance{overflow:visible}
|
||||
body.site .strip-label{display:block;font-size:.85rem;font-weight:600;margin-bottom:6px}
|
||||
body.site .strip-label+.days{margin-bottom:14px}
|
||||
body.site .strip-none{color:var(--muted);margin:0 0 14px}
|
||||
body.site .book-r .help{font-size:.8rem;color:var(--muted);margin:6px 0 0}
|
||||
body.site .book-r .err{font-size:.82rem;color:#9B2C2C;margin:6px 0 0}
|
||||
body.site .book-r .ok{font-size:.9rem;color:var(--olive);margin:0 0 14px}
|
||||
body.site .book-r h3{font-size:1.5rem;margin-bottom:10px}
|
||||
body.site .book-r .summary{display:grid;grid-template-columns:auto 1fr;gap:6px 18px;margin:16px 0 18px;font-size:.95rem}
|
||||
body.site .book-r .summary dt{font-weight:600}
|
||||
body.site .book-r .summary dd{margin:0}
|
||||
body.site .book-r-note{text-align:center}
|
||||
body.site .book-r-note .btn{width:auto;display:inline-flex}
|
||||
body.site .book-r .btn+.btn{margin-top:10px}
|
||||
@media (max-width:1100px){body.site .book{grid-template-columns:1fr}}
|
||||
@media (max-width:760px){
|
||||
body.site .book-l{padding:40px 24px}
|
||||
body.site .book-r{padding:26px 18px;margin:8px}
|
||||
body.site .f2{grid-template-columns:1fr}
|
||||
}
|
||||
|
||||
/* ---------- Reveal (hidden only when JavaScript runs) ---------- */
|
||||
body.site.js [data-reveal]{opacity:0;transform:translateY(28px);transition:opacity .9s var(--ease),transform .9s var(--ease)}
|
||||
body.site.js [data-reveal].in{opacity:1;transform:none}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use App\Services\AvailabilityService;
|
||||
use Carbon\Carbon;
|
||||
use Livewire\Attributes\Reactive;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
@@ -18,12 +19,54 @@ new class extends Component
|
||||
|
||||
public array $bookedDates = [];
|
||||
|
||||
public string $variant = 'month';
|
||||
|
||||
#[Reactive]
|
||||
public ?string $pickedDate = null;
|
||||
|
||||
#[Reactive]
|
||||
public ?string $pickedTime = null;
|
||||
|
||||
public function mount(array $bookedDates = []): void
|
||||
{
|
||||
$this->bookedDates = $bookedDates;
|
||||
$this->year = now()->year;
|
||||
$this->month = now()->month;
|
||||
$this->loadMonthAvailability();
|
||||
|
||||
if ($this->variant === 'strip') {
|
||||
$first = $this->buildStripDays()[0]['date'] ?? null;
|
||||
|
||||
if ($first) {
|
||||
$this->selectedDate = $first;
|
||||
$this->loadAvailableSlots();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The next 14 days (looking up to 60 days ahead) that can be booked.
|
||||
*
|
||||
* @return array<int, array{date: string, label: string, day: int}>
|
||||
*/
|
||||
private function buildStripDays(): array
|
||||
{
|
||||
$service = app(AvailabilityService::class);
|
||||
$days = [];
|
||||
|
||||
for ($i = 0; $i < 60 && count($days) < 14; $i++) {
|
||||
$date = today()->addDays($i);
|
||||
|
||||
if (in_array($service->getDateStatus($date), ['available', 'partial'])) {
|
||||
$days[] = [
|
||||
'date' => $date->format('Y-m-d'),
|
||||
'label' => $date->translatedFormat('l'),
|
||||
'day' => $date->day,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
public function loadMonthAvailability(): void
|
||||
@@ -65,7 +108,9 @@ new class extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $this->monthAvailability[$date] ?? 'unavailable';
|
||||
$status = $this->variant === 'strip'
|
||||
? app(AvailabilityService::class)->getDateStatus(Carbon::parse($date))
|
||||
: ($this->monthAvailability[$date] ?? 'unavailable');
|
||||
|
||||
if (in_array($status, ['available', 'partial'])) {
|
||||
$this->selectedDate = $date;
|
||||
@@ -151,11 +196,47 @@ new class extends Component
|
||||
'calendarDays' => $this->buildCalendarDays(),
|
||||
'dayHeaders' => $this->getDayHeaders(),
|
||||
'bookedDates' => $this->bookedDates,
|
||||
'stripDays' => $this->variant === 'strip' ? $this->buildStripDays() : [],
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
@if($variant === 'strip')
|
||||
@if(count($stripDays) > 0)
|
||||
<span class="strip-label">{{ __('site.booking_day') }}</span>
|
||||
<div class="days" role="group" aria-label="{{ __('site.booking_day') }}">
|
||||
@foreach($stripDays as $day)
|
||||
<button
|
||||
type="button"
|
||||
class="day"
|
||||
aria-pressed="{{ $selectedDate === $day['date'] ? 'true' : 'false' }}"
|
||||
wire:click="selectDate('{{ $day['date'] }}')"
|
||||
>
|
||||
{{ $day['label'] }}
|
||||
<b>{{ $day['day'] }}</b>
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<span class="strip-label">{{ __('site.booking_time') }}</span>
|
||||
<div class="slots" role="group" aria-label="{{ __('site.booking_time') }}">
|
||||
@foreach($availableSlots as $slot)
|
||||
<button
|
||||
type="button"
|
||||
class="slot"
|
||||
dir="ltr"
|
||||
aria-pressed="{{ $pickedDate === $selectedDate && $pickedTime === $slot ? 'true' : 'false' }}"
|
||||
wire:click="$parent.selectSlot('{{ $selectedDate }}', '{{ $slot }}')"
|
||||
>
|
||||
{{ \Carbon\Carbon::parse($slot)->format('g:i A') }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="strip-none">{{ __('site.booking_none') }}</p>
|
||||
@endif
|
||||
@else
|
||||
<!-- Calendar Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<flux:button size="sm" wire:click="previousMonth" variant="outline">
|
||||
@@ -248,4 +329,5 @@ new class extends Component
|
||||
<span class="text-zinc-600">{{ __('booking.unavailable') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -37,8 +37,12 @@ new #[Layout('components.layouts.public')] class extends Component
|
||||
|
||||
public bool $isLoggedIn = false;
|
||||
|
||||
public function mount(): void
|
||||
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;
|
||||
@@ -192,6 +196,86 @@ new #[Layout('components.layouts.public')] class extends Component
|
||||
}
|
||||
}; ?>
|
||||
|
||||
@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
|
||||
<div class="wrap py-8">
|
||||
<div class="max-w-4xl mx-auto py-8 px-4">
|
||||
<flux:heading size="xl" class="mb-6 text-body">
|
||||
@@ -369,3 +453,4 @@ new #[Layout('components.layouts.public')] class extends Component
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -170,5 +170,22 @@ new #[Layout('components.layouts.public')] class extends Component
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- booking card: task 24-5 --}}
|
||||
<section id="book">
|
||||
<div class="wrap">
|
||||
<div class="book" data-reveal>
|
||||
<div class="book-l on-dark">
|
||||
<span class="eyebrow">{{ __('site.art_booking') }}</span>
|
||||
<h2>{{ __('site.booking_title') }}</h2>
|
||||
<p>{{ __('site.booking_lede') }}</p>
|
||||
<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="finale" style="width:190px;height:190px;margin-top:22px" />
|
||||
</div>
|
||||
<livewire:pages.booking :compact="true" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\ConsultationStatus;
|
||||
use App\Models\Consultation;
|
||||
use App\Models\User;
|
||||
use App\Models\WorkingHour;
|
||||
use App\Services\AvailabilityService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
RateLimiter::clear('guest-booking:127.0.0.1');
|
||||
});
|
||||
|
||||
function openEveryDay(): void
|
||||
{
|
||||
foreach (range(0, 6) as $dow) {
|
||||
WorkingHour::factory()->create(['day_of_week' => $dow, 'is_active' => true, 'start_time' => '09:00', 'end_time' => '13:00']);
|
||||
}
|
||||
}
|
||||
|
||||
function firstBookableDate(): string
|
||||
{
|
||||
$service = app(AvailabilityService::class);
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$d = today()->addDays($i);
|
||||
if (in_array($service->getDateStatus($d), ['available', 'partial'])) {
|
||||
return $d->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('no bookable day');
|
||||
}
|
||||
|
||||
function fillCompactForm($component, string $date, string $time = '09:00')
|
||||
{
|
||||
return $component
|
||||
->call('selectSlot', $date, $time)
|
||||
->set('guestName', 'Test Guest')
|
||||
->set('guestPhone', '+970599000000')
|
||||
->set('guestEmail', 'guest@example.com')
|
||||
->set('problemSummary', 'A sufficiently long description of my case.')
|
||||
->set('captchaAnswer', session('captcha_answer'));
|
||||
}
|
||||
|
||||
test('strip variant lists up to 14 bookable days, selects the first and loads its slots', function () {
|
||||
openEveryDay();
|
||||
$first = firstBookableDate();
|
||||
|
||||
$component = Volt::test('availability-calendar', ['variant' => 'strip'])
|
||||
->assertSet('selectedDate', $first)
|
||||
->assertSee('class="days"', false)
|
||||
->assertSee('class="slots"', false)
|
||||
->assertDontSee('calendar-grid', false);
|
||||
|
||||
expect(substr_count($component->html(), 'class="day"'))->toBeGreaterThan(0)->toBeLessThanOrEqual(14);
|
||||
expect($component->get('availableSlots'))->not->toBeEmpty();
|
||||
|
||||
$second = collect(range(1, 59))->map(fn ($i) => today()->addDays($i)->format('Y-m-d'))
|
||||
->first(fn ($d) => $d > $first && in_array(app(AvailabilityService::class)->getDateStatus(Carbon::parse($d)), ['available', 'partial']));
|
||||
$component->call('selectDate', $second)->assertSet('selectedDate', $second);
|
||||
});
|
||||
|
||||
test('strip variant shows the none message when nothing is bookable', function () {
|
||||
Volt::test('availability-calendar', ['variant' => 'strip'])->assertSee(__('site.booking_none'));
|
||||
});
|
||||
|
||||
test('default variant is still the month calendar', function () {
|
||||
Volt::test('availability-calendar')->assertSet('variant', 'month')->assertSee('calendar-grid', false)->assertDontSee('class="days"', false);
|
||||
});
|
||||
|
||||
test('compact booking renders the form in the specified order', function () {
|
||||
openEveryDay();
|
||||
$html = Volt::test('pages.booking', ['compact' => true])->html();
|
||||
|
||||
$order = ['wire:model="guestName"', 'wire:model="guestPhone"', 'wire:model="guestEmail"', 'class="days"', 'class="slots"',
|
||||
'wire:model="problemSummary"', 'wire:model="captchaAnswer"', 'btn btn-gold', 'class="note"'];
|
||||
$last = -1;
|
||||
foreach ($order as $needle) {
|
||||
$pos = strpos($html, $needle);
|
||||
expect($pos)->not->toBeFalse("missing $needle");
|
||||
expect($pos)->toBeGreaterThan($last);
|
||||
$last = $pos;
|
||||
}
|
||||
expect($html)->toContain('book-r');
|
||||
});
|
||||
|
||||
test('a guest can book from the compact card and lands on success', function () {
|
||||
openEveryDay();
|
||||
$date = firstBookableDate();
|
||||
|
||||
$c = Volt::test('pages.booking', ['compact' => true]);
|
||||
fillCompactForm($c, $date)->call('showConfirm')->assertHasNoErrors()->assertSet('showConfirmation', true)
|
||||
->call('submit')->assertRedirect(route('booking.success'));
|
||||
|
||||
expect(Consultation::count())->toBe(1);
|
||||
$row = Consultation::first();
|
||||
expect($row->guest_email)->toBe('guest@example.com')->and($row->status)->toBe(ConsultationStatus::Pending)->and($row->user_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('compact booking keeps the same validation as the page', function () {
|
||||
openEveryDay();
|
||||
$date = firstBookableDate();
|
||||
|
||||
$c = Volt::test('pages.booking', ['compact' => true]);
|
||||
fillCompactForm($c, $date)->set('problemSummary', 'too short')->call('showConfirm')->assertHasErrors(['problemSummary']);
|
||||
|
||||
$c = Volt::test('pages.booking', ['compact' => true]);
|
||||
fillCompactForm($c, $date)->set('captchaAnswer', 'wrong')->call('showConfirm')->assertHasErrors(['captchaAnswer']);
|
||||
|
||||
$c = Volt::test('pages.booking', ['compact' => true])
|
||||
->set('guestName', 'Test Guest')->set('guestPhone', '1')->set('guestEmail', 'a@b.co')
|
||||
->set('problemSummary', 'A sufficiently long description of my case.')->set('captchaAnswer', session('captcha_answer'))
|
||||
->call('showConfirm')->assertHasErrors(['selectedTime']);
|
||||
|
||||
expect(Consultation::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('one booking per email per day is enforced in compact mode', function () {
|
||||
openEveryDay();
|
||||
$date = firstBookableDate();
|
||||
Consultation::factory()->create(['guest_name' => 'Other', 'guest_phone' => '123', 'guest_email' => 'guest@example.com', 'booking_date' => $date, 'booking_time' => '12:00', 'status' => ConsultationStatus::Pending, 'user_id' => null]);
|
||||
|
||||
$c = Volt::test('pages.booking', ['compact' => true]);
|
||||
fillCompactForm($c, $date)->call('showConfirm')->assertHasErrors(['guestEmail']);
|
||||
});
|
||||
|
||||
test('logged in clients see the message instead of the compact form', function () {
|
||||
$user = User::factory()->client()->create();
|
||||
|
||||
Volt::actingAs($user)->test('pages.booking', ['compact' => true])
|
||||
->assertSee(__('booking.logged_in_title'))
|
||||
->assertSee(route('client.consultations.book'), false)
|
||||
->assertDontSee('wire:model="guestName"', false);
|
||||
});
|
||||
|
||||
test('home page has the booking card with the finale balance', function () {
|
||||
$html = $this->get('/')->assertOk()->getContent();
|
||||
|
||||
expect($html)->toContain('id="book"')->toContain('class="book"')->toContain('book-l on-dark')
|
||||
->toContain('data-balance="finale"')->toContain('tel:+970599353502')->toContain(__('footer.email'))->toContain('book-r');
|
||||
});
|
||||
|
||||
test('the booking page itself is not compact', function () {
|
||||
$html = $this->get('/booking')->assertOk()->getContent();
|
||||
expect($html)->not->toContain('book-r')->toContain('calendar-grid');
|
||||
});
|
||||
|
||||
test('booking card keys exist in both languages', function () {
|
||||
foreach (['ar', 'en'] as $locale) {
|
||||
$t = require base_path("lang/$locale/site.php");
|
||||
foreach (['art_booking', 'booking_title', 'booking_lede', 'booking_day', 'booking_time', 'booking_submit', 'booking_none'] as $k) {
|
||||
expect($t)->toHaveKey($k);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user