From b1ac864573bc7a0c9cd1d396447a62816b6a143c Mon Sep 17 00:00:00 2001 From: Naser Mansour Date: Sat, 31 Oct 2026 18:49:28 +0200 Subject: [PATCH] =?UTF-8?q?24-5:=20home=20booking=20card=20(strip=20varian?= =?UTF-8?q?t,=20compact=20booking)=20=E2=80=94=20implemented,=20QA=20Revie?= =?UTF-8?q?w;=20footer=20brand=20layout=20fix=20from=2024-2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- docs/tasks/24-5.md | 2 +- docs/tasks/manual-tests/24-5.md | 20 +++ docs/tasks/statuses.md | 2 +- lang/ar/site.php | 7 + lang/en/site.php | 7 + resources/css/public.css | 52 ++++++ .../livewire/availability-calendar.blade.php | 84 +++++++++- .../views/livewire/pages/booking.blade.php | 87 +++++++++- resources/views/livewire/pages/home.blade.php | 19 ++- tests/Feature/Design/HomeBookingCardTest.php | 158 ++++++++++++++++++ 10 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 docs/tasks/manual-tests/24-5.md create mode 100644 tests/Feature/Design/HomeBookingCardTest.php diff --git a/docs/tasks/24-5.md b/docs/tasks/24-5.md index 78c6d3d..220b911 100644 --- a/docs/tasks/24-5.md +++ b/docs/tasks/24-5.md @@ -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 --- diff --git a/docs/tasks/manual-tests/24-5.md b/docs/tasks/manual-tests/24-5.md new file mode 100644 index 0000000..8ebfedf --- /dev/null +++ b/docs/tasks/manual-tests/24-5.md @@ -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. diff --git a/docs/tasks/statuses.md b/docs/tasks/statuses.md index fb48172..d4b7f59 100644 --- a/docs/tasks/statuses.md +++ b/docs/tasks/statuses.md @@ -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 | diff --git a/lang/ar/site.php b/lang/ar/site.php index a825982..ce89a85 100644 --- a/lang/ar/site.php +++ b/lang/ar/site.php @@ -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' => 'لا توجد مواعيد متاحة حالياً', ]; diff --git a/lang/en/site.php b/lang/en/site.php index b066360..92e616f 100644 --- a/lang/en/site.php +++ b/lang/en/site.php @@ -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', ]; diff --git a/resources/css/public.css b/resources/css/public.css index f025bf2..536e230 100644 --- a/resources/css/public.css +++ b/resources/css/public.css @@ -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} diff --git a/resources/views/livewire/availability-calendar.blade.php b/resources/views/livewire/availability-calendar.blade.php index 9495c05..b7636c2 100644 --- a/resources/views/livewire/availability-calendar.blade.php +++ b/resources/views/livewire/availability-calendar.blade.php @@ -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 + */ + 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() : [], ]; } }; ?>
+@if($variant === 'strip') + @if(count($stripDays) > 0) + {{ __('site.booking_day') }} +
+ @foreach($stripDays as $day) + + @endforeach +
+ + {{ __('site.booking_time') }} +
+ @foreach($availableSlots as $slot) + + @endforeach +
+ @else +

{{ __('site.booking_none') }}

+ @endif +@else
@@ -248,4 +329,5 @@ new class extends Component {{ __('booking.unavailable') }}
+@endif diff --git a/resources/views/livewire/pages/booking.blade.php b/resources/views/livewire/pages/booking.blade.php index 64cb116..060eb8c 100644 --- a/resources/views/livewire/pages/booking.blade.php +++ b/resources/views/livewire/pages/booking.blade.php @@ -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) +
+

{{ __('booking.logged_in_title') }}

+

{{ __('booking.logged_in_message') }}

+ {{ __('booking.go_to_dashboard') }} +
+ @elseif($showConfirmation) +
+

{{ __('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.problem_summary') }}
{{ $problemSummary }}
+
+ @error('selectedTime') + + @enderror + + +
+ @else +
+ @if(session('success')) +

{{ session('success') }}

+ @endif + +
+
+ + + @error('guestName')

{{ $message }}

@enderror +
+
+ + + @error('guestPhone')

{{ $message }}

@enderror +
+
+ +
+ + + @error('guestEmail')

{{ $message }}

@enderror +
+ +
+ + @error('selectedTime')

{{ $message }}

@enderror + @error('selectedDate')

{{ $message }}

@enderror +
+ +
+ + +

{{ __('booking.problem_summary_help') }}

+ @error('problemSummary')

{{ $message }}

@enderror +
+ +
+ + + @error('captchaAnswer')

{{ $message }}

@enderror +
+ + +

{{ __('booking.limit_message') }}

+
+ @endif +@else
@@ -369,3 +453,4 @@ new #[Layout('components.layouts.public')] class extends Component @endif
+@endif diff --git a/resources/views/livewire/pages/home.blade.php b/resources/views/livewire/pages/home.blade.php index 708438b..7c7e96d 100644 --- a/resources/views/livewire/pages/home.blade.php +++ b/resources/views/livewire/pages/home.blade.php @@ -170,5 +170,22 @@ new #[Layout('components.layouts.public')] class extends Component - {{-- booking card: task 24-5 --}} +
+
+
+
+ {{ __('site.art_booking') }} +

{{ __('site.booking_title') }}

+

{{ __('site.booking_lede') }}

+
+ {{ __('footer.phone') }} + {{ __('footer.email') }} + {{ __('footer.address') }} +
+ +
+ +
+
+
diff --git a/tests/Feature/Design/HomeBookingCardTest.php b/tests/Feature/Design/HomeBookingCardTest.php new file mode 100644 index 0000000..8093486 --- /dev/null +++ b/tests/Feature/Design/HomeBookingCardTest.php @@ -0,0 +1,158 @@ +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); + } + } +});