Compare commits

..

18 Commits

Author SHA1 Message Date
Naser Mansour 88c0a65aa9 docs: gold-ink token note, redesign gotchas in project CLAUDE.md
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:26:47 +02:00
Naser Mansour ad7f531edd auth: language toggle active label readable (white on olive); link colour rule no longer applies to it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:26:29 +02:00
Naser Mansour 379f1f9de6 QA: sticky nav, hover lift, body font and auth focus fixes; tasks 24 and 24-1..24-10 Done
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:23:46 +02:00
Naser Mansour f1ca648672 tests: retire or rewrite old About/Home/Responsive/Navigation assertions superseded by the redesign
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:10:12 +02:00
Naser Mansour 9263966266 24-10: redesign integration verification — contrast fix, booking component root fix, tests; QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:03:57 +02:00
Naser Mansour 01ff077a5c 24-9: auth pages type and controls — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:55:09 +02:00
Naser Mansour 2c068277a2 24-8: articles and legal pages — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:54:18 +02:00
Naser Mansour 7879ac7190 24-7: about page — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:52:36 +02:00
Naser Mansour 7637294593 24-6: booking page and booking success — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:51:15 +02:00
Naser Mansour b1ac864573 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>
2026-10-31 18:49:28 +02:00
Naser Mansour 10a4e1f4c0 24-4: home page sections — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:46:09 +02:00
Naser Mansour d8b27a15e8 24-3: signature balance component — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:44:10 +02:00
Naser Mansour 8b2c35f76f 24-2: navigation and footer — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:43:25 +02:00
Naser Mansour 5f5d1d2b31 24-1: QA — manual checklist written
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:41:51 +02:00
Naser Mansour bcabb2ad2d 24-1: public design foundation (stylesheet, script, fonts, layout) — implemented, QA Review
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:41:12 +02:00
Naser Mansour 3d1fe40be6 docs: client-approved public-site redesign — PRD 1.4, UX spec v2.0, architecture §6.2, tasks 24-x Ready for Dev
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 18:23:02 +02:00
Naser Mansour caec396fa4 docs: website redesign mockup v3 (proposal, pending client decision)
Self-contained HTML mockup of the public-site redesign: Arabic-first, engraved
gold balance over olive photography, Markazi Text + IBM Plex Sans Arabic.
Checked against the code (booking component, post model, public layout); no
unbacked promises. Spec (docs/ux-design.md) waits for the client's yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:38:34 +02:00
Naser Mansour 1d549ac0e6 Update CLAUDE.md: correct production server, mail host, deploy checklist
Server IP was stale (pre-migration host, 69.62.106.94) - production has
been on h1 (163.172.67.77) for a while. Documents the MAIL_HOST incident,
the first-time git linking done today, and that PHP-FPM + queue worker
restarts are needed on every deploy, not just composer/config changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 00:51:56 +03:00
76 changed files with 5122 additions and 1525 deletions
+71 -8
View File
@@ -10,9 +10,31 @@ Libra Law Firm is a bilingual (Arabic/English) web platform for managing client
## Production Environment
- **Server:** Rocky Linux 9.6 on 69.62.106.94
- **PHP:** 8.4 (use `php84` command, not `php`)
- **Database:** MariaDB/MySQL
- **Server:** h1 (`h1.elenoslabs.com`, `163.172.67.77`) — Rocky Linux 9, ElenosLabs-owned. **Not**
`69.62.106.94` — that stale IP is left over from before the migration to h1 and does not serve
this app; a past `.env` even had `MAIL_HOST` pointed at it by mistake (see *Mail* below). Full
server details: `wiki/prod_servers/elenoslabs/h1server.md` (Jarvis workspace), § libra.adv.ps.
- **App user:** `libra` — app root `/home/libra/public_html/`, logs `/home/libra/logs/`
- **PHP:** 8.4 (use `php84` command — there is no bare `php` binary on this box; even Composer
needs `php84 /usr/local/bin/composer ...`)
- **Database:** MariaDB, Redis (sessions/cache/queue)
- **Queue worker:** `libra-queue.service` (`queue:work redis`) — a **long-running process**, see
the queue note below.
- **Git:** production was file-copied at initial migration and only properly linked to git for
the first time on 2026-09-28 (`git checkout -f -B main origin/main` after backing up the prior
state) — before that date, `git pull` on production did nothing (empty local branch, no
commits). It is now a normal git-tracked checkout; a plain `git pull` works going forward.
### Mail
`MAIL_HOST` must be `mx1.elenoslabs.com` (h1's own Postfix/Dovecot stack, MX for `adv.ps`) — **not**
`mail.adv.ps`, which resolves to the stale `69.62.106.94` host and times out. This caused a real
incident (2026-09-28): the workshop-registration form's emails silently never arrived because of
this wrong value, and a separate real bug in `App\Services\AdminNotificationService` (a Carbon
`TypeError` on a string `throttle_minutes` config value — fixed, see git history around
2026-09-28) masked the actual SMTP error in the logs the whole time it was broken. If mail seems
to silently fail again, check `MAIL_HOST` first, then `storage/logs/laravel.log` for a
`TransportException`.
### Production Commands
All artisan commands on production require `php84` prefix:
@@ -24,16 +46,25 @@ php84 artisan optimize:clear
### Deployment Checklist
```bash
# On production server
composer install --no-dev --optimize-autoloader
php84 artisan migrate --force
# On production server (as the libra user, in /home/libra/public_html)
git pull
php84 /usr/local/bin/composer install --no-dev --optimize-autoloader # only if composer.json/lock changed
npm install && npm run build # only if frontend/Tailwind classes changed
php84 artisan optimize:clear
php84 artisan config:cache
php84 artisan route:cache
php84 artisan view:cache
# IMPORTANT: Always restart PHP-FPM after deployment
# IMPORTANT: always restart PHP-FPM after ANY deploy, even a content/lang-only change.
# 2026-09-28 incident: opcache.validate_timestamps is On with revalidate_freq=2, which should
# have picked up a changed lang/*.php file within 2 seconds on its own — it didn't, and only a
# full php84-php-fpm restart made the change visible. Root cause not fully diagnosed; until it
# is, treat the restart as mandatory on every deploy, not just composer/config changes.
sudo systemctl restart php84-php-fpm
# IMPORTANT: always restart the queue worker too — it's a long-running process that holds old
# code and old .env/config values in memory until restarted, independent of the php-fpm restart.
php84 artisan queue:restart
```
**Note:** The server has both PHP 8.3 and 8.4. Always use `php84` for artisan commands. Failing to restart PHP-FPM can cause cached config issues.
@@ -85,7 +116,7 @@ php artisan make:model ModelName -mf
- `app/Enums/` - UserType, UserStatus, ConsultationType, ConsultationStatus, PaymentStatus, TimelineStatus, PostStatus
- `docs/prd.md` - Full product requirements document
- `docs/architecture.md` - Complete architecture document
- `docs/ux-design.md` - Design system reference (colors, typography, layout, components — retrospective, written 2026-09-27)
- `docs/ux-design.md` - Design spec. **Part A** = the public-site redesign (approved by the client 2026-10-31, mockup `docs/ux-mockup.html`); **Part B** = the pre-redesign system, which still governs the dashboards, emails and the Nablus form
- `docs/tasks/` - **Current task/status tracker** (`statuses.md` is the status source of truth). One row per epic (parent) and per story (subtask), e.g. `3.md` / `3-4.md`.
- `docs/stories/`, `docs/epics/`, `docs/qa/gates/` - **Legacy.** Original BMAD-agent build records (98→102 stories across 16 epics). Kept for implementation history and traceability, no longer the status source — `docs/tasks/statuses.md` superseded them 2026-09-27. `.bmad-core/` and `.claude/commands/BMad/` are the retired BMAD toolchain, also kept but unused.
@@ -215,7 +246,39 @@ Located in `app/Notifications/`:
- **Cascade deletes:** User deletion cascades to consultations, timelines, notifications
- **Bilingual required:** All user-facing text needs Arabic and English
## Public-site redesign (PRD 1.4, tasks 24-x)
The public pages (home, about, posts, booking, legal) and the auth pages' type/controls follow
`docs/ux-design.md` Part A; the map is `docs/architecture.md` §6.2. Boundaries that keep the
dashboards safe:
- Redesign styles live in `resources/css/public.css`, every selector under `body.site` (auth:
`body.site-auth`); its script is `resources/js/public.js` + `resources/js/site/*`, initialised on
`livewire:navigated`. Both load only from the public (and, CSS only, auth) layouts.
- Never edit `resources/css/app.css` `@theme`/`.btn-*`, `components/language-toggle.blade.php`,
or `lang/*/navigation.php` for the redesign — the dashboards share them. New public copy goes in
`lang/{ar,en}/site.php`.
- `availability-calendar` defaults to `variant="month"` (the client dashboard uses it); the home
page uses `variant="strip"`. `pages.booking` takes `compact` for the home card; booking rules are
shared and unchanged.
Gotchas found in the 24-x QA (2026-10-31) — all come from `app.css`/shared markup reaching into `body.site`:
- `app.css` sets `overflow-x:hidden` on `body` (breaks `position:sticky`) and the body font via
`html[lang] body` (beats `body.site`): `public.css` overrides both (`overflow-x:clip`,
`html[lang] body.site`). Any new public rule that must beat `app.css` needs the same care.
- Reveal uses the `translate` property, not `transform`, so hover lifts (`transform`) still work.
- Flux always renders a hidden `data-flux-error`, so `app.css` paints every Flux input red; auth
styles override it unless the error is visible (`:not(.hidden)`).
- A Volt template that starts with a Blade `@if` gets no Livewire root (form unbound, only visible
in the browser console): wrap such templates in one root `<div>`.
- A broad link-colour rule on auth pages must exclude `[data-test^=language-switch]`: its active
label is white on olive, and an olive link colour erased it.
- After a change, check text against its background (contrast of active, hover and filled states)
in a screenshot, not only layout metrics.
## Design Requirements (from PRD)
Below: the dashboards' palette and rules. Public pages: Part A of `docs/ux-design.md`.
- **Color scheme:**
- Dark Forest Green (#2D3624) - Header/Footer backgrounds
- Warm Gold (#A68966) - CTA buttons, links, accents
+64 -2
View File
@@ -1,7 +1,7 @@
# Libra Law Firm - Architecture Document
**Version:** 1.1
**Date:** December 21, 2025
**Version:** 1.2
**Date:** October 31, 2026
**Status:** Approved
**Domain:** libra.ps
@@ -13,6 +13,7 @@
|------|---------|-------------|--------|
| Dec 21, 2025 | 1.0 | Initial architecture document | Winston (Architect) |
| Dec 21, 2025 | 1.1 | Added CI/CD, rollback procedures, monitoring, timezone handling, alternatives analysis | Winston (Architect) |
| Oct 31, 2026 | 1.2 | Added §6.2 Public-site presentation layer (PRD 1.4 redesign, ux-design.md v2.0 Part A) | Jarvis |
---
@@ -1008,6 +1009,67 @@ libra/
└── build/ # Compiled assets
```
> §6.1 is the original (Dec 2025) plan. As built, the public layout is
> `resources/views/components/layouts/public.blade.php` (not `guest.blade.php`) and public pages
> are Volt components under `resources/views/livewire/pages/`. §6.2 is current.
### 6.2 Public-Site Presentation Layer (redesign, PRD 1.4)
**Overview.** The October 2026 redesign (PRD Update 1.4; `docs/ux-design.md` Part A) is a
presentation-only change to the public pages. No route, model, migration, service, mail or job
changes. The redesign is isolated from the admin and client dashboards by loading its stylesheet
and script only from the public and auth layouts, and by scoping every rule under a body class.
**Components**
| Component | Path | Owns |
|---|---|---|
| Public stylesheet | `resources/css/public.css` (new Vite entry) | Part A tokens (`--forest`, `--gold`, …), linen ground, type, buttons, sections, cards, docket rows, booking card, footer, reveal and reduced-motion rules. Every selector is scoped under `body.site`; auth-page rules under `body.site-auth`. |
| Public script | `resources/js/public.js` (new Vite entry) imports `resources/js/site/balance.js`, `reveal.js`, `progress.js` | The balance SVG builder and damped spring, hero pointer response, finale scroll mapping, IntersectionObserver reveal, reading-progress rule, nav scrolled state. Initialises on `livewire:navigated` (fires on first load and after every `wire:navigate`) and tears down listeners on `livewire:navigating`. |
| Font partial | `resources/views/partials/site-fonts.blade.php` (new) | Google Fonts link for Markazi Text (400–700) and IBM Plex Sans Arabic (300–600), `display=swap`. |
| Public layout | `resources/views/components/layouts/public.blade.php` (changed) | Body class `site`; includes the font partial and `@vite(['resources/css/public.css','resources/js/public.js'])` after `partials.head`; drops the 1200px content wrapper and the 64px fixed-nav spacer (nav becomes sticky); adds the progress rule element. |
| Auth layouts | `resources/views/components/layouts/auth/split.blade.php`, `card.blade.php`, `simple.blade.php` (changed) | Body class `site-auth`; font partial; `public.css` only (no public script). Composition unchanged. |
| Navigation / footer | `resources/views/components/navigation.blade.php`, `footer.blade.php` (restyled in place; used only by the public layout) | Part A nav and footer. Keeps the existing Alpine mobile menu, auth-aware links and language toggle. |
| Site Blade components | `resources/views/components/site/` (new): `balance.blade.php` (SVG mount point; props `id`, `size`, `mode` = `hero` / `finale` / `static`), `section-head.blade.php` (article label + h2 + lede), `post-card.blade.php` (text-only post card) | Shared markup for the public pages. |
| Redesign copy | `lang/ar/site.php`, `lang/en/site.php` (new) | Strings introduced by the redesign only (hero line, trust strip, pillars, article labels, card titles, booking lede, nav and footer labels). Existing keys in `home.php`, `about.php`, `booking.php`, `footer.php`, `navigation.php` are reused, never duplicated or edited. `navigation.php` keys used by the dashboards are untouched. |
| Photography | `public/images/site/olive-tree.jpg`, `embroidery-hoop.jpg`, `olives.jpg`, `hills.jpg` (new; extracted from the data URIs in `docs/ux-mockup.html`) | Production images (Unsplash licence). Portrait stays `public/images/huda-armouche.jpg`; seal uses `public/images/logo.png`. |
| Guest booking | `resources/views/livewire/pages/booking.blade.php` (changed) | Gains `public bool $compact = false` (mount parameter). `compact` renders the one-screen home layout; default renders the `/booking` page layout. Validation, captcha, one-per-day rule, confirm step and redirect to `booking.success` are shared and unchanged. |
| Availability calendar | `resources/views/livewire/availability-calendar.blade.php` (changed) | Gains `public string $variant = 'month'`. `strip` lists the next 14 dates whose `AvailabilityService::getDateStatus()` is available (scanning at most 60 days ahead) and their `getAvailableSlots()`; `month` is the existing grid. Default stays `month`, so the client dashboard's booking page (`resources/views/livewire/client/consultations/book.blade.php`) is unaffected; its public restyle applies only under `body.site`. |
**Data flows**
1. Request to a public route → Volt page with `#[Layout('components.layouts.public')]` → layout
loads `app.css` + `app.js` (unchanged, via `partials.head`), then `public.css` + `public.js`.
2. Home `/` → `Post::published()->latest('published_at')->take(3)` (existing) → `x-site.post-card`;
embeds `<livewire:pages.booking :compact="true" />`, which embeds
`<livewire:availability-calendar variant="strip" />`. Slot choice → `$parent.selectSlot(date,
time)` (existing contract) → submit → existing confirm step → existing `submit()` → redirect
`/booking/success`.
3. `public.js` on `livewire:navigated` → finds `[data-balance]` mounts → builds the SVG → binds
pointer (hero, fine pointers only) and scroll (finale) → spring per `requestAnimationFrame`.
`prefers-reduced-motion: reduce` → every balance drawn level, no listeners bound.
4. Language: unchanged — `/language/{locale}` sets the session locale; layouts set `lang` and
`dir`.
**Module boundaries**
- The redesign owns `public.css`, `public.js`, `resources/js/site/*`, `components/site/*`,
`lang/*/site.php`, `public/images/site/*`, and the restyle of the public views listed above.
- It does not own and must not change: `resources/css/app.css` `@theme` tokens and `.btn-*`
classes, Flux configuration, anything under `resources/views/livewire/admin/` or
`livewire/client/`, `components/layouts/app*`, emails, PDFs,
`resources/views/livewire/pages/forms/nablus-workshop.blade.php`, `layouts/standalone.blade.php`,
routes, models, services (read-only use of `AvailabilityService`), migrations.
- Pages restyled: `livewire/pages/home.blade.php`, `about.blade.php`, `booking.blade.php`,
`booking-success.blade.php`, `posts/index.blade.php`, `posts/show.blade.php`,
`resources/views/pages/legal.blade.php` (and the `terms`/`privacy` wrappers if they render
markup).
- `partials/head.blade.php`: `theme-color` corrected to `#2D3624` (was the superseded
`#4A4A42`); nothing else changes there, because the dashboards share it.
**Open gaps** — none blocking. The four rulings (photography, 14-day strip, unmocked pages, auth
scope) are recorded in `docs/ux-design.md` Part A *Open Questions* and the project ledger.
---
## 7. Authentication & Authorization
+57 -1
View File
@@ -1,7 +1,7 @@
# Product Requirements Document (PRD)
## Libra Law Firm Website
**Version:** 1.2
**Version:** 1.4
**Date:** January 3, 2026
**Domain:** libra.ps
**Prepared for:** Development Team & Client Review
@@ -1106,6 +1106,7 @@ All emails in both Arabic and English based on user preference:
| 1.1 | Dec 20, 2025 | Added brand identity section, clarified booking rules (1/day, 45min), multiple timelines, account management (deactivate/delete), exports, analytics, Terms/Privacy pages | Development Team |
| 1.2 | Jan 3, 2026 | Added guest booking functionality: public booking form at /booking, custom captcha, 1-per-day limit, guest email notifications, admin guest booking management | Development Team |
| 1.3 | Sep 27, 2026 | Added standalone Nablus workshop registration form (unlinked page at /forms/nablus-workshop) — see Update below | Development Team |
| 1.4 | Oct 31, 2026 | Public-site redesign approved by the client (mockup v3): new visual system for the public pages, compact booking card on the home page — see Update below | Development Team |
---
@@ -1145,4 +1146,59 @@ None outstanding — all decisions confirmed directly with Naser in the same ses
---
## Update — Version 1.4
**Date:** 2026-10-31
### New Features Added
**Public-site redesign.** ElenosLabs proposed a redesign of Libra's public website (mockup v3,
`docs/ux-mockup.html`); the client approved it on 2026-10-31. Scope answers given by Naser on
2026-10-31: scope "Public site" (home, about, posts, booking, auth); brand "Keep logo, evolve
rest" (the logo and the forest/gold/cream palette stay; type, layout and motion change); driver
"Our pitch". Naser's constraint on content: "we dont need to promice smth we wont deliver" — every
claim on the new pages is backed by existing behaviour. The full visual specification is
`docs/ux-design.md` Part A.
- **New visual system on every public page** — `/`, `/about`, `/posts`, `/posts/{post}`,
`/booking`, `/booking/success`, `/page/{slug}`: Markazi Text (display) and IBM Plex Sans Arabic
(body); the brand palette used in full-bleed forest bands, gold rules and a linen cream ground;
sections numbered as articles; a sticky translucent navigation with a reading-progress rule; a
four-column forest footer.
- **Signature balance** — an engraved-gold scales-of-justice graphic over olive-tree photography in
the home hero, settling into balance on load and responding to the pointer; a second balance in
the booking card comes level as the reader arrives. All motion respects
`prefers-reduced-motion`.
- **Redesigned home page** — hero, pillars (Justice, Dignity, Advocacy), founder, services docket,
vision with photography, values, latest three articles (text-only cards), and an embedded
booking card.
- **Compact booking on the home page** — the home page embeds the existing guest-booking form as
one screen (name, phone, email, a 14-day strip of days with free slots, slot buttons, case
summary, captcha), with the same rules as `/booking` (required fields, 20-character minimum
summary, captcha, one booking per email per day, slots from the availability settings, the
confirm step, the success page). This realises §5.1's "Embedded simplified booking form".
- **Auth pages** — the new type and form controls; the split layout is unchanged.
- **Production photography** — four licensed Unsplash photographs (olive tree, embroidery hoop,
olives, hills) and the founder's existing portrait.
### Scope Changes
- §5.1 and §7 (Design Requirements) are superseded for the public pages by `docs/ux-design.md`
Part A. §7's typography (Cairo/Montserrat), button radius and card specs continue to apply to
the admin and client dashboards, which are unchanged.
- §5.2 navigation gains a "Services" link (to the home page's services section); the menu labels
on the public site read Home, About, Services, Articles, Booking.
- Out of scope: admin and client dashboards, emails, PDF exports, `/forms/nablus-workshop`, any
backend or database change, post images or categories, new content beyond the redesign's
section labels and short lines approved in the mockup.
### Open Questions
None blocking. Rulings recorded in the project ledger (2026-10-31): the mockup's Unsplash photos
ship to production (replaceable by the client's own later); the home day strip covers the next 14
days with free slots; pages not shown in the mockup are composed from its components with their
existing content; auth pages change type and controls only. The build price is not yet set (a
commercial matter, not a product requirement).
---
**END OF DOCUMENT**
+147
View File
@@ -0,0 +1,147 @@
---
id: 24-1
title: 24-1 Public Design Foundation (Stylesheet, Script, Fonts, Layout)
status: Done
date: 2026-10-31
parent: 24
---
## Description
Create the shared foundation every later redesign subtask builds on. It must be loaded only by
the public layout (and, for the stylesheet only, by the auth layouts in task 24-9), and every rule
must be scoped under `body.site` so the admin and client dashboards are unaffected.
The approved visual reference is the mockup file `docs/ux-mockup.html` (committed in `caec396`).
Where this task says "port lines X–Y", copy those CSS rules from that file and prefix every
selector with `body.site ` (for example `.btn-gold` → `body.site .btn-gold`; `:root{…}` →
`body.site{…}`; `body{…}` → `body.site{…}`; `html[dir=ltr] .x` → `html[dir=ltr] body.site .x`;
`html[lang=en] .x` → `html[lang=en] body.site .x`). The mockup's lines contain no images, so line
numbers are exact.
### Files to create
1. **`resources/css/public.css`** — contains, in this order:
- Tokens on `body.site` (from mockup lines 12–29):
`--forest:#2D3624; --forest-2:#232A1C; --gold:#A68966; --gold-ink:#8A7555; --gold-lt:#C4A882;
--cream:#F4F1EA; --olive:#4A5D23; --ink:#1E2219; --muted:#5B6152; --line:rgba(45,54,36,.14);
--display:"Markazi Text","Times New Roman",serif; --body:"IBM Plex Sans Arabic",system-ui,sans-serif;
--r:10px; --ease:cubic-bezier(.2,.7,.2,1);
--shadow-1:0 1px 2px rgba(30,34,25,.06),0 4px 14px rgba(30,34,25,.06);
--shadow-2:0 2px 4px rgba(30,34,25,.08),0 18px 40px -12px rgba(30,34,25,.28);`
- Page ground and type on `body.site` (port lines 32–39): `font-family:var(--body);
color:var(--ink); font-size:17px; line-height:1.75; background-color:var(--cream)` plus the
linen weave `background-image: repeating-linear-gradient(0deg, rgba(45,54,36,.025) 0 1px,
transparent 1px 3px), repeating-linear-gradient(90deg, rgba(45,54,36,.022) 0 1px, transparent
1px 4px)`. This overrides the inline `style="font-family: var(--font-…)"` on the body, so the
layout's inline style is removed (see layout changes below).
- Port lines 40–50 (img, links, `.wrap`, skip link, focus-visible, h1–h3, `.eyebrow`,
`.art-no`). `.wrap` = `max-width:1200px; margin-inline:auto; padding-inline:28px`.
Focus = `outline:2px solid var(--gold); outline-offset:3px; border-radius:4px`.
- Buttons: port lines 52–68 (`.btn`, `.btn-gold` with shimmer `::after`, `.btn-ghost`,
`.on-dark .btn-ghost`, `.btn .arr` and LTR mirroring). Class names `.btn`, `.btn-gold`,
`.btn-ghost` do not exist in `resources/css/app.css` (which uses `.btn-primary`,
`.btn-secondary`), and they are scoped under `body.site`, so there is no collision.
- Progress rule: port lines 70–73 (`#hem` → rename to `.site-progress`; fixed, top 0, z 60,
height 2px, gradient `linear-gradient(90deg,#8A7555,#E2CDA4,#A68966)`, `transform:scaleX(0)`,
origin right; `html[dir=ltr]` origin left).
- Sections: port lines 139–144 (`section{position:relative}`, `.sec{padding:120px 0}`,
`.sec-head`, `.sec-head h2`, `.sec-head p`).
- Reveal: port lines 286–290, changed so content is 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}`,
and the `data-d` 1–5 delays (.08s, .16s, .24s, .32s, .4s).
- Responsive base: at `max-width:760px`: `body.site{font-size:16px}`,
`body.site .wrap{padding-inline:18px}`, `body.site .sec{padding:80px 0}`.
- Reduced motion (port lines 324–327): under `@media (prefers-reduced-motion:reduce)`,
`body.site *, body.site *::before, body.site *::after{animation:none!important;
transition:none!important; scroll-behavior:auto!important}` and
`body.site [data-reveal]{opacity:1;transform:none}`.
- `html:has(body.site){scroll-behavior:smooth}`.
2. **`resources/js/public.js`** — imports `./site/reveal.js` and `./site/progress.js`, and calls
each module's `init()` on the `livewire:navigated` document event (Livewire 3 dispatches it on
the first page load and after every `wire:navigate`), and each module's `destroy()` on
`livewire:navigating`. On init it adds the class `js` to `document.body`.
3. **`resources/js/site/reveal.js`** — exports `init()`/`destroy()`. If
`matchMedia('(prefers-reduced-motion: reduce)').matches`, add class `in` to every
`[data-reveal]` and return. Otherwise create one `IntersectionObserver` with
`rootMargin: '0px 0px -8% 0px'`; when an observed element intersects, add class `in` and
unobserve it. `destroy()` disconnects the observer.
4. **`resources/js/site/progress.js`** — exports `init()`/`destroy()`. Adds a passive `scroll`
listener on `window` that sets `.site-progress` `style.transform = scaleX(p)` where
`p = max > 0 ? scrollY / max : 1` and `max = document.documentElement.scrollHeight - innerHeight`,
and toggles class `scrolled` on `#site-nav` when `scrollY > 8` (if that element exists; it is
added in task 24-2). Runs once on init. `destroy()` removes the listener.
5. **`resources/views/partials/site-fonts.blade.php`** — exactly:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Markazi+Text:wght@400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600&display=swap" rel="stylesheet">
```
6. **`lang/ar/site.php`** and **`lang/en/site.php`** — each `<?php return [ ... ];` with the
keys `progress_label` (ar `'تقدّم القراءة'`, en `'Reading progress'`). Later subtasks add keys
to these files.
### Files to modify
7. **`vite.config.js`** — `input` becomes
`['resources/css/app.css', 'resources/js/app.js', 'resources/css/public.css', 'resources/js/public.js']`.
8. **`resources/views/components/layouts/public.blade.php`**:
- After `@include('partials.head')` add `@include('partials.site-fonts')` and
`@vite(['resources/css/public.css', 'resources/js/public.js'])`.
- `<body>` becomes `<body class="site min-h-screen flex flex-col">` (remove `bg-background`
and the inline `style="font-family: …"`).
- Immediately after the skip link add
`<div class="site-progress" aria-hidden="true"></div>`.
- Remove the spacer `<div class="h-16"></div>` (the navigation becomes sticky in 24-2; until
then the existing fixed navigation may overlap the first 64px — acceptable only between
24-1 and 24-2).
- `<main>` keeps `id="main-content" role="main" class="flex-1" tabindex="-1"` but its child
`<div class="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">` is removed, so
`{{ $slot }}` is rendered directly inside `<main>`.
9. Because the wrapper is gone, wrap the root content of each existing public page that is not
yet redesigned in `<div class="wrap py-8">…</div>` so these pages keep a contained width until
their own subtask restyles them:
- `resources/views/livewire/pages/home.blade.php`
- `resources/views/livewire/pages/about.blade.php` (also remove the negative margins
`-mx-4 sm:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8` from its first `<section>`)
- `resources/views/livewire/pages/booking.blade.php`
- `resources/views/livewire/pages/booking-success.blade.php`
- `resources/views/livewire/pages/posts/index.blade.php`
- `resources/views/livewire/pages/posts/show.blade.php`
- `resources/views/pages/legal.blade.php`
- `resources/views/pages/terms.blade.php`
- `resources/views/pages/privacy.blade.php`
(`resources/views/pages/booking.blade.php` is a dead placeholder — `/booking` is served by the
Volt page — and is left untouched.)
For a Volt page whose root is a single element, put the wrapper inside the root element so the
component keeps one root.
10. **`resources/views/partials/head.blade.php`** — change
`<meta name="theme-color" content="#4A4A42">` to `<meta name="theme-color" content="#2D3624">`.
Change nothing else in this file (the dashboards share it).
Do not modify `resources/css/app.css` or any file under `resources/views/livewire/admin/`,
`resources/views/livewire/client/`, `resources/views/components/layouts/app*`.
## Acceptance Criteria
- [ ] `npm run build` succeeds and `public/build/manifest.json` contains entries for
`resources/css/public.css` and `resources/js/public.js`.
- [ ] Every selector in `resources/css/public.css` is scoped under `body.site` (or
`html[...] body.site`, or `html:has(body.site)`), verify:
`grep -nE '^[^@/ }].*\{' resources/css/public.css | grep -v 'body.site'` returns nothing.
- [ ] The HTML of `/` contains `<body class="site`, the `Markazi+Text` font link, a
`public.css` and a `public.js` asset, and `<div class="site-progress"`.
- [ ] The HTML of `/admin/dashboard` (logged in as admin) and `/client/dashboard` (logged in as a
client) contains neither `public.css`, `public.js`, `Markazi` nor `class="site`.
- [ ] `resources/css/app.css` is unchanged: `git diff --quiet BASE -- resources/css/app.css`.
- [ ] On `/` with the browser at 390px wide, the document has no horizontal scroll, and every
public page listed in item 9 still renders its content inside a 1200px-max container.
- [ ] Scrolling `/` changes the `.site-progress` element's transform from `scaleX(0)` toward
`scaleX(1)`; with `prefers-reduced-motion: reduce` emulated, every `[data-reveal]` element
is visible without scrolling.
- [ ] `partials/head.blade.php` contains `content="#2D3624"` and no `#4A4A42`.
## Sub-tasks
None.
+63
View File
@@ -0,0 +1,63 @@
---
id: 24-10
title: 24-10 Redesign Integration Verification
status: Done
date: 2026-10-31
parent: 24
---
## Description
Verify the finished redesign as a whole before it is offered for deployment, and fix any defect
found within the files touched by tasks 24-1 to 24-9. This task adds no feature. Build the assets
(`npm run build`) first so the pages are not rendered from stale CSS.
Check, on the local site (`https://libra.test` via Valet, or `php artisan serve`), with at least
three published posts in the database and working hours configured so slots exist:
1. **Pages** — `/`, `/about`, `/posts`, one `/posts/{post}`, `/booking`, `/booking/success`,
`/page/terms`, `/page/privacy`, `/login`, `/forgot-password`: each in Arabic (RTL) and English
(LTR), each at 1440, 768 and 390px wide.
2. **Booking end-to-end** — one guest booking from the home card and one from `/booking`, each
reaching `/booking/success` and each appearing in the admin's pending bookings
(`/admin/bookings/pending`).
3. **Unchanged areas** — `/admin/dashboard`, `/admin/bookings/pending`, `/admin/posts`,
`/client/dashboard`, `/client/consultations/book`, `/forms/nablus-workshop`.
4. **Motion** — load `/` with and without `prefers-reduced-motion: reduce` emulated.
5. **Navigation** — move between public pages with the navigation links (`wire:navigate`) and back
to `/`.
Fix defects in the redesign's own files only: `resources/css/public.css`, `resources/js/public.js`,
`resources/js/site/*.js`, `resources/views/components/site/*.blade.php`,
`resources/views/components/navigation.blade.php`, `resources/views/components/footer.blade.php`,
`resources/views/components/layouts/public.blade.php`,
`resources/views/components/layouts/auth/*.blade.php`, `resources/views/partials/site-fonts.blade.php`,
`resources/views/livewire/pages/home.blade.php`, `about.blade.php`, `booking.blade.php`,
`booking-success.blade.php`, `posts/index.blade.php`, `posts/show.blade.php`,
`resources/views/livewire/availability-calendar.blade.php`, `resources/views/pages/legal.blade.php`,
`terms.blade.php`, `privacy.blade.php`, `lang/ar/site.php`, `lang/en/site.php`.
## Acceptance Criteria
- [ ] Every page in item 1, in both languages and at all three widths, has no horizontal scroll
(`document.documentElement.scrollWidth <= innerWidth`), no console error, and no missing
translation key (no raw `site.` or `home.` key text visible).
- [ ] Each page in item 1 has exactly one `<h1>`, every `<img>` has a non-empty `alt`, and every
interactive element in the navigation, footer and booking card is reachable by Tab with a
visible gold focus ring.
- [ ] Both bookings in item 2 succeed and appear in `/admin/bookings/pending`.
- [ ] The pages in item 3 contain no `public.css`, `public.js`, `class="site` or `site-auth`, and
`git diff BASE -- resources/css/app.css resources/views/livewire/admin resources/views/livewire/client resources/views/components/layouts/app.blade.php resources/views/components/layouts/app resources/views/livewire/pages/forms`
(BASE = the commit recorded before task 24-1) is empty.
- [ ] With reduced motion emulated, all `[data-reveal]` content is visible on load and every
balance is level; without it, the hero balance settles and the finale levels on scroll.
- [ ] After five `wire:navigate` hops ending on `/`, the hero balance and reveal still work and the
finale still levels.
- [ ] Contrast: body text on cream, `--gold-lt` text on forest, and the gold button text meet
WCAG AA (4.5:1 for body text, 3:1 for large text) — measured, values noted in the task's QA
record.
- [ ] No page contains the text "Unsplash" or "mockup".
## Sub-tasks
None.
+146
View File
@@ -0,0 +1,146 @@
---
id: 24-2
title: 24-2 Navigation and Footer
status: Done
date: 2026-10-31
parent: 24
---
## Description
Restyle the public navigation and footer to the approved redesign. Both components are used only
by `resources/views/components/layouts/public.blade.php`, so they are restyled in place. Keep all
existing behaviour: the Alpine `x-data="{ mobileMenuOpen: false }"` menu with its
`x-trap.inert.noscroll`, `@click.away` and `@keydown.escape.window` handling, the `@auth` branch
(Dashboard link to `route('admin.dashboard')` or `route('client.dashboard')` by
`auth()->user()->isAdmin()`, and the logout `<form method="POST" action="{{ route('logout') }}">`),
the language toggle's `/language/{locale}` links, and `wire:navigate` on internal links.
All CSS goes in `resources/css/public.css` (created in 24-1), every selector prefixed with
`body.site `. Port the CSS from `docs/ux-mockup.html`: navigation lines 75–98, footer lines
273–284, and the responsive nav/footer rules inside lines 293–323 (`.links` hidden and
`.menu-btn` shown at ≤1100px; `.nav .btn` and `.login` hidden at ≤760px; footer grid 2 columns at
≤1100px and 1 column at ≤760px).
### Navigation — `resources/views/components/navigation.blade.php`
- Root: `<header class="nav" id="site-nav">` — sticky (`position:sticky; top:0; z-index:50`),
height 72px, background `rgba(244,241,234,.82)`, `backdrop-filter: blur(14px) saturate(1.2)`
(with `-webkit-` prefix), bottom border transparent; with class `scrolled` (toggled by
`resources/js/site/progress.js` from 24-1 when `scrollY > 8`) the border becomes
`1px solid var(--line)` and the shadow `0 8px 24px -18px rgba(30,34,25,.5)`. Remove the old
fixed positioning and forest background.
- Inner `<div class="wrap">` laid out as flex, `align-items:center; gap:28px; height:72px`:
1. Brand link to `route('home')`: `<span class="seal">` (46×46px, background
`#fff url('/images/logo.png') center/cover no-repeat`, `border:2px solid #1A1A1A`,
`box-shadow:3px 3px 5px rgba(26,26,26,.35)`) then a two-line wordmark: `<b>` =
`__('site.brand_name')` (display font, 1.35rem, line-height 1) and `<small>` =
`__('site.brand_sub')` (.72rem, `--muted`; letter-spacing .12em in English).
`aria-label="{{ __('site.brand_label') }}"`.
2. `<nav class="links" aria-label="{{ __('site.nav_main') }}">` with links: Home →
`route('home')`, About → `route('about')`, Services → `route('home').'#services'`,
Articles → `route('posts.index')`, Booking → `route('booking')`. Labels from `site.nav_*`.
The current route's link gets `aria-current="page"` (home for `home`, about for `about`,
articles for `posts.*`, booking for `booking*`). Link style: padding 10px 14px, weight 500,
.95rem, radius 8px; hover colour `--olive`; `::after` underline — `inset-inline:14px;
bottom:4px; height:2px; background:repeating-linear-gradient(90deg,var(--gold) 0 4px,
transparent 4px 7px); transform:scaleX(0)`, origin right (left in LTR), transition
`.35s var(--ease)`; `scaleX(1)` on hover and on `[aria-current]`.
3. `.nav-tools` (flex, gap 10px): replace both `<x-language-toggle />` usages in this file
(desktop and mobile) with a plain link
`<a class="lang" href="{{ url('/language/'.(app()->getLocale() === 'ar' ? 'en' : 'ar')) }}" aria-label="{{ __('site.lang_switch') }}">`
— a pill (min 48×44px, inline-grid centred, radius 999px, `1.5px solid var(--line)`, white,
weight 600 .85rem, no underline; hover gold border and lift 1px) whose text is `EN` when the
locale is `ar` and `ع` when it is `en`. Do not edit
`resources/views/components/language-toggle.blade.php` (the auth layouts and the dashboard
sidebar use it); for guests a `.login` link (`__('site.nav_login')` →
`route('login')`, .92rem, min-height 44px, hover olive); for authenticated users the
existing Dashboard link and logout button styled like `.login`; then
`<a class="btn btn-gold" href="{{ route('home') }}#book">{{ __('site.nav_book') }}</a>`
(in the nav: min-height 44px, padding 10px 20px, .92rem); then the mobile menu button
`.menu-btn` (44×44px, radius 10px, `1.5px solid var(--line)`, white, hidden above 1100px)
carrying the existing Alpine toggle, `:aria-expanded`, and
`aria-label="{{ __('site.nav_menu') }}"`.
- Mobile menu sheet (the existing `x-show="mobileMenuOpen"` panel): cream background
(`var(--cream)`), directly under the nav, full width, each link a 48px-high row with a
`1px solid var(--line)` bottom border; contains the five links, the login (or Dashboard and
logout) and the gold Book button full width.
### Footer — `resources/views/components/footer.blade.php`
- `<footer class="site-footer">`: background `var(--forest-2)`, colour `var(--gold-lt)`,
margin-top 120px, padding 72px 0 28px, .92rem, position relative. First child
`<span class="foot-band" aria-hidden="true">` — absolute, top 0, full width, height 1px,
`linear-gradient(90deg,transparent,rgba(196,168,130,.7),transparent)`.
- `<div class="wrap"><div class="foot">` grid `1.4fr 1fr 1fr 1fr`, gap 40px:
1. The brand mark (same seal + wordmark as the nav; wordmark `<b>` cream, `<small>` gold-lt)
and `<p>` = `__('home.tagline')` (margin-top 16px, max-width 26em).
2. `<h4>{{ __('site.footer_explore') }}</h4>` then links About → `route('about')`, Services →
`route('home').'#services'`, Articles → `route('posts.index')`, Booking → `route('booking')`.
3. `<h4>{{ __('site.footer_clients') }}</h4>` then Client login → `route('login')`, Terms →
`route('terms')` with `__('site.footer_terms')`, Privacy → `route('privacy')` with
`__('site.footer_privacy')`.
4. `<h4>{{ __('site.footer_contact') }}</h4>` then `<a href="tel:+970599353502" dir="ltr">`
`{{ __('footer.phone') }}`, `<a href="mailto:{{ __('footer.email') }}">`, and the address
`{{ __('footer.address') }}` as plain text.
- `h4`: display font, 1.15rem, cream, margin 0 0 14px. Links: block, padding 10px 0, hover
colour cream and `translateX(-3px)` (RTL) / `translateX(3px)` (LTR).
- `.legal` row: flex, space-between, wrap, `border-top:1px solid rgba(244,241,234,.1)`,
margin-top 48px, padding-top 22px, .8rem, colour `rgba(196,168,130,.75)`; content
`© {{ date('Y') }} {{ __('footer.copyright') }}`. No photography credit.
### Language keys — add to `lang/ar/site.php` and `lang/en/site.php`
| Key | ar | en |
|---|---|---|
| `brand_name` | ليبرا | Libra |
| `brand_sub` | للحقوق | FOR RIGHTS |
| `brand_label` | ليبرا للحقوق | Libra for Rights |
| `nav_main` | القائمة الرئيسية | Main |
| `nav_home` | الرئيسية | Home |
| `nav_about` | من نحن | About |
| `nav_services` | الخدمات | Services |
| `nav_articles` | المقالات | Articles |
| `nav_booking` | الحجز | Booking |
| `nav_login` | دخول العملاء | Client login |
| `nav_book` | احجز استشارة | Book a consultation |
| `nav_menu` | القائمة | Menu |
| `lang_switch` | English | العربية |
| `footer_explore` | تصفّح | Explore |
| `footer_clients` | العملاء | Clients |
| `footer_contact` | تواصل | Contact |
| `footer_terms` | شروط الاستخدام | Terms of use |
| `footer_privacy` | سياسة الخصوصية | Privacy policy |
Do not edit `lang/*/navigation.php` or `lang/*/footer.php` (the dashboards use `navigation.php`).
### Files
- Modify: `resources/views/components/navigation.blade.php`,
`resources/views/components/footer.blade.php`, `resources/css/public.css`,
`lang/ar/site.php`, `lang/en/site.php`.
## Acceptance Criteria
- [ ] On `/` at 1440px the navigation is 72px high, stays at the top of the viewport while
scrolling, shows brand, five links, language pill, Client login and the gold Book button,
and gains a bottom border once the page is scrolled past 8px.
- [ ] On `/about` the About link has `aria-current="page"`; on `/posts` the Articles link does.
- [ ] At ≤1100px the five links are hidden and the menu button opens a sheet containing all five
links, login and Book; Escape closes it; at 390px there is no horizontal scroll.
- [ ] Logged in as a client, the navigation shows Dashboard (to `/client/dashboard`) and a working
Log out instead of Client login; logged in as admin, Dashboard goes to `/admin/dashboard`.
- [ ] The language pill reads `EN` on Arabic pages and `ع` on English pages and switches locale;
`resources/views/components/language-toggle.blade.php` is unchanged against BASE.
- [ ] The footer shows four columns at 1440px with the phone, email and address from
`lang/*/footer.php`, links to `/terms` and `/privacy`, and `© <current year>`; it contains no
"Unsplash" text.
- [ ] Every key in the table exists in both `lang/ar/site.php` and `lang/en/site.php`, and
`lang/*/navigation.php` and `lang/*/footer.php` are unchanged against BASE.
- [ ] The admin and client dashboards' navigation is unchanged (they do not render
`components/navigation.blade.php`): verify `grep -rn "x-navigation" resources/views` lists
only `components/layouts/public.blade.php`.
## Sub-tasks
None.
+81
View File
@@ -0,0 +1,81 @@
---
id: 24-3
title: 24-3 Signature Balance Component
status: Done
date: 2026-10-31
parent: 24
---
## Description
Build the redesign's signature: an engraved-gold balance (scales of justice) rendered as an
inline SVG, with three behaviours — `hero` (settles on load, weighed by the pointer), `finale`
(comes level as the reader scrolls to the booking card) and `static` (always level). Pages place
it in later subtasks (24-4 hero and vision card, 24-5 finale, 24-6 success page).
### Files to create
1. **`resources/views/components/site/balance.blade.php`** — props: `mode` (`hero` | `finale` |
`static`, default `static`), `label` (default null), `size` (integer px, default null; when set,
the SVG gets `width` and `height` of that many px). Renders
`<svg class="site-balance" data-balance="{{ $mode }}" viewBox="0 0 400 400" …>` with
`overflow:visible`; when `label` is set: `role="img" aria-label="{{ $label }}"`, otherwise
`aria-hidden="true"`. Pass through `$attributes` (class, id, style).
2. **`resources/js/site/balance.js`** — exports `init()` and `destroy()`; imported and wired in
`resources/js/public.js` exactly like `reveal.js` (init on `livewire:navigated`, destroy on
`livewire:navigating`). It contains:
- `build(svg)` — fills the SVG with the markup from `docs/ux-mockup.html` lines 566–595
(the `scales()` function's template: four gradients `gd`, `gc`, `gb`, `gh`, two filters
`sh` (drop shadow dy 8, stdDeviation 7, flood `#0d1009` opacity .45) and `bl` (blur 6), the
ground shadow ellipse, base, pillar, olive-leaf finial, the `.beam` group, the pivot, and two
`.pan` groups). Gradient and filter ids must be unique per instance (suffix a running
counter) so several balances on one page do not share ids. Returns `tilt(deg)` (mockup lines
597–602): `beam.setAttribute('transform', rotate(deg 200 110))`; with
`r = deg·π/180, c = cos(r)·142, s = sin(r)·142`, pan 0 at `translate(200−c, 110−s)` and pan 1
at `translate(200+c, 110+s)`.
- `spring(tilt, start)` (mockup lines 605–610): state `a = start, v = 0, target = 0`; each
animation frame `v += (target − a)·0.028; v ·= 0.935; a += v; tilt(a)`; keep requesting
frames while `|v| > 0.002` or `|target − a| > 0.01`. Exposes `set(target)`.
- Behaviour per `data-balance`:
- `static`: `tilt(0)`.
- `hero`: `tilt(14)`, create a spring from 14, after 450ms `set(0)`. If
`matchMedia('(pointer:fine)').matches`, find the closest ancestor `.frame` (the hero frame,
built in 24-4); on `pointermove` over it: `x = (clientX − rect.left) / rect.width`,
`set(clamp((x − 0.5)·2.4, −1, 1)·9)`; move the frame's `.glow` element's `left`/`top` to
the pointer position within the frame; set the frame's `img` transform to
`scale(1.08) translate(${(clientX − rect.left − rect.width/2)/−40}px, ${(clientY − rect.top − rect.height/2)/−40}px)`.
On `pointerleave`: `set(0)` and clear the image transform.
- `finale`: `tilt(−12)`, spring from −12; on a passive window `scroll` listener (and once on
init): `p = scrollY / (scrollHeight − innerHeight)` (1 if the denominator is ≤ 0),
`q = clamp((p − 0.7) / 0.25, 0, 1)`, `set(−12·(1 − q))`.
- If `matchMedia('(prefers-reduced-motion: reduce)').matches`: every balance is built and
`tilt(0)`; no spring, no listeners.
- `destroy()` removes every listener added and cancels pending animation frames.
3. CSS in **`resources/css/public.css`**: `body.site .site-balance{display:block;overflow:visible}`.
### Files to modify
- `resources/js/public.js` (import and wire `./site/balance.js`), `resources/css/public.css`.
## Acceptance Criteria
- [ ] `<x-site.balance mode="hero" label="ميزان العدالة" />` renders an `<svg>` with
`data-balance="hero"`, `viewBox="0 0 400 400"`, `role="img"` and the given `aria-label`;
without `label` it renders `aria-hidden="true"`.
- [ ] With two balances on one page, every `id` inside the SVGs is unique
(`document.querySelectorAll('[id]')` has no duplicate values).
- [ ] A `hero` balance is drawn tilted on load and its beam's `transform` reaches `rotate(0…)`
(|angle| < 0.05) within 4 seconds without pointer input.
- [ ] Moving a fine pointer to the right edge of the hero frame tips the beam toward +9° (in the
browser, read the beam's transform); leaving the frame returns it toward 0°.
- [ ] A `finale` balance's beam is at −12° at the top of the page and within 0.5° of 0° when the
page is scrolled to the bottom.
- [ ] With `prefers-reduced-motion: reduce` emulated, every balance's beam transform is
`rotate(0 200 110)` and no animation frame is requested after load.
- [ ] Navigating between public pages with `wire:navigate` and back to `/` re-initialises the
hero balance (it renders and settles) and leaves no duplicate scroll listeners (the finale
still levels correctly after three navigations).
## Sub-tasks
None.
+196
View File
@@ -0,0 +1,196 @@
---
id: 24-4
title: 24-4 Home Page Sections (Hero to Latest Articles)
status: Done
date: 2026-10-31
parent: 24
---
## Description
Rebuild `resources/views/livewire/pages/home.blade.php` as the approved redesign, sections 1–7
(hero, pillars, founder, services, vision, values, latest articles). Section 8, the booking card,
is task 24-5; leave a placeholder comment `{{-- booking card: task 24-5 --}}` where it goes. Keep
the component's PHP class as it is (`#[Layout('components.layouts.public')]`, `with()` returning
`latestPosts` = `Post::published()->latest('published_at')->take(3)->get()`). Remove the
temporary `<div class="wrap py-8">` wrapper added in 24-1: each section is full-bleed and holds a
`<div class="wrap">`.
The visual reference is `docs/ux-mockup.html` (commit `caec396`). Port the CSS for each section
from the line ranges below into `resources/css/public.css`, every selector prefixed with
`body.site `, and port the matching responsive rules from lines 293–323. Put `data-reveal` and
`data-d` attributes on the same elements as the mockup (lines 358–506).
### Photography — create `public/images/site/`
Extract these JPEG data URIs from `docs/ux-mockup.html` and save them, decoded, as files (a short
script reading the file and base64-decoding the `src="data:image/jpeg;base64,…"` value on each
line is enough; do not commit the script):
| Mockup line | `<img>` | Save as |
|---|---|---|
| 377 | hero olive tree | `public/images/site/olive-tree.jpg` |
| 447 | embroidery hoop | `public/images/site/embroidery-hoop.jpg` |
| 455 | olives on a branch | `public/images/site/olives.jpg` |
| 459 | hills and road | `public/images/site/hills.jpg` |
The founder portrait uses the existing `public/images/huda-armouche.jpg`. The images are from
Unsplash (licence permits commercial use without attribution); show no credit line.
### Sections (in order, inside the root `<div>`)
1. **Hero** — `<section class="hero" id="top">`; CSS lines 100–125. Two-column grid
(`1fr 1.08fr`, gap 56px; one column ≤1100px). Start column: `.eyebrow` = `site.hero_eyebrow`;
`<h1>` = `{{ __('site.hero_title_a') }}<em>{{ __('site.hero_title_em') }}</em>{{ __('site.hero_title_b') }}`
(the `em` is upright, `--olive`, with `text-decoration: underline dashed var(--gold) .07em;
text-underline-offset:.28em`); `<p class="lede">` = `home.intro`; CTA row: gold button
`home.cta_book` → `#book` with the arrow SVG (mockup line 366), ghost button
`home.cta_services` → `#services`; `.trust` strip with three items, each
`<strong>` + `<span>`: (`site.trust_1_title`, `site.trust_1_text`), (`site.trust_2_title`,
`site.trust_2_text`), (`site.trust_3_title`, `site.trust_3_text`). End column `.hero-art`:
`<figure class="frame">` (aspect 4/4.6, radius `999px 999px 18px 18px`, forest ground,
gradient veil `::before` from `rgba(35,42,28,.35)` to `.9`), containing
`<img src="/images/site/olive-tree.jpg" alt="{{ __('site.alt_olive_tree') }}">` (filter
`saturate(.75) contrast(1.05)`, `scale(1.08)`), `<span class="glow">` (420px radial gold
glow), `<x-site.balance mode="hero" :label="__('site.balance_label')" id="scales" />`
positioned `inset:10% 8% 12%`, and `<figcaption>` = `site.frame_caption` (display italic,
cream .78). Then `<div class="tag">` with `<span class="dot">` (olive, pulsing) and
`site.frame_tag`.
2. **Pillars band** — `<section class="band on-dark" aria-label="{{ __('site.pillars_label') }}">`;
CSS lines 127–137. Three `.pillar` columns: (`site.pillar_justice`, `site.pillar_justice_text`),
(`site.pillar_dignity`, `site.pillar_dignity_text`), (`site.pillar_advocacy`,
`site.pillar_advocacy_text`), each title preceded by `<span class="gem">` (CSS line 210).
3. **Founder** — `<section class="sec" id="founder">`; CSS lines 146–157. Portrait
`/images/huda-armouche.jpg` (alt `home.lawyer_name`) with `.namecard`
(`<b>` `home.lawyer_name`, `<span>` `site.founder_role`). Text column: `.art-no`
`site.art_founder`; `<h2>` `home.about_title`; `<blockquote>` `site.founder_quote`;
paragraphs `home.lawyer_bio` and `home.lawyer_bio_2`; ghost button `site.founder_cta` →
`route('about')`.
4. **Services docket** — `<section class="sec" id="services" style="padding-top:40px">`;
CSS lines 159–174. `x-site.section-head` (see below) with label `site.art_services`, title
`home.services_subtitle`, lede `site.services_lede`. Four `<a class="row" href="#book">` rows:
number `01`–`04` (`.n`), `<h3>` title, `.chips` of the items, `.go` arrow circle. Rows in order:
(`home.service_consultation_title`, `home.service_consultation_items`),
(`home.service_family_title`, `home.service_family_items`),
(`home.service_civil_title`, `home.service_civil_items`),
(`home.service_business_title`, `home.service_business_items`). Items are arrays — render each
as `<span class="chip">`.
5. **Vision** — `<section class="sec fabric" id="mission">`; CSS lines 176–197. Text column:
`.art-no` `site.art_vision`, `<h2>` `site.vision_title`, `<p class="big">` `site.vision_text`;
beside it `.hoop` with `/images/site/embroidery-hoop.jpg` (alt `site.alt_hoop`; filter
`hue-rotate(-140deg) saturate(.55) sepia(.25)`). Below, `.three` grid of `<article class="card">`:
(a) `.ph.motif-ph` containing `<x-site.balance mode="static" />` (150×150px) and label
`site.card_institution_label`, title `site.card_institution_title`, text
`site.card_institution_text`; (b) `.ph` with `/images/site/olives.jpg` (alt `site.alt_olives`),
label `site.card_society_label`, title `site.card_society_title`, text `site.card_society_text`;
(c) `.ph` with `/images/site/hills.jpg` (alt `site.alt_hills`), label
`site.card_diaspora_label`, title `site.card_diaspora_title`, text `site.card_diaspora_text`.
6. **Values** — `<section class="sec" id="values">`; CSS lines 199–212. Section head: label
`site.art_values`, title `home.values_title`, lede `home.values_subtitle`. `.sampler` grid of six
`.val` cards, each `<span class="medal">` with the line icon and `<b>` =
`home.values.<key>.title`, keys in order `integrity`, `justice`, `knowledge`, `empowerment`,
`professionalism`, `innovation`. Icons: the inline SVGs on mockup lines 474–479 in the same
order (24×24 viewBox, `aria-hidden="true"`, stroke `--gold-ink` 1.5, no fill). Replace the
current `flux:icon` usage.
7. **Latest articles** — `<section class="sec" id="posts" style="padding-top:20px">`; CSS lines
214–234. Head: label `site.art_articles`, title `home.posts_title`, and in place of the lede a
ghost button `home.view_all_posts` → `route('posts.index')`. If `$latestPosts` is not empty:
`.posts` grid of `<x-site.post-card :post="$post" />`; otherwise a `<p>` with
`home.posts_empty` (muted, centred).
### Components to create
- **`resources/views/components/site/section-head.blade.php`** — props `label`, `title`,
`lede` (nullable); renders
`<div class="sec-head"><div><span class="art-no" data-reveal>{{ $label }}</span><h2 data-reveal data-d="1">{{ $title }}</h2></div>`
then either `<p data-reveal data-d="2">{{ $lede }}</p>` or the `$slot` (for the button), then
`</div>`.
- **`resources/views/components/site/post-card.blade.php`** — prop `post` (App\Models\Post);
`<a class="post" href="{{ route('posts.show', $post) }}" wire:navigate data-reveal>` →
`<div class="bd">` with `<span class="meta">` = the published date
(`$post->published_at?->translatedFormat('j F Y')`), `<h3>` = `$post->getTitle()`, `<p>` = an
excerpt of `strip_tags($post->getBody())` limited to 140 characters (`Str::limit`), and
`<span class="more">` = `home.read_more`. Text-only: no image element.
### Language keys — add to `lang/ar/site.php` and `lang/en/site.php`
| Key | ar | en |
|---|---|---|
| `hero_eyebrow` | ليبرا للحقوق · فلسطين | Libra for Rights · Palestine |
| `hero_title_a` | العدالة␠ | Justice is a␠ |
| `hero_title_em` | ممارسةٌ يومية | daily practice |
| `hero_title_b` | ، لا مبدأٌ نظري. | , not a theory. |
| `trust_1_title` | أفراد وشركات | Individuals & companies |
| `trust_1_text` | استشارات وتمثيل | Consultations and representation |
| `trust_2_title` | المرأة والأسرة | Women & family |
| `trust_2_text` | دعم قانوني متخصص | Specialised legal support |
| `trust_3_title` | عربي · English | Arabic · English |
| `trust_3_text` | خدمة بلغتين | Bilingual service |
| `alt_olive_tree` | شجرة زيتون معمّرة بين صخور كلسية | An ancient olive tree among limestone rocks |
| `balance_label` | ميزان العدالة | The scales of justice |
| `frame_caption` | لكلّ حقٍّ وزنُه | Every right carries its weight |
| `frame_tag` | استشارات حضورية أو عن بُعد | Consultations in person or remotely |
| `pillars_label` | ركائزنا | Our pillars |
| `pillar_justice` | العدالة | Justice |
| `pillar_justice_text` | الإنصاف، والمساءلة، وسيادة القانون. | Fairness, accountability and the rule of law. |
| `pillar_dignity` | الكرامة | Dignity |
| `pillar_dignity_text` | القيمة الأصيلة لكل إنسان. | The inherent worth of every person. |
| `pillar_advocacy` | الدفاع | Advocacy |
| `pillar_advocacy_text` | انخراطٌ فاعل، وقولُ الحق في وجه السلطة. | Active engagement, and speaking truth to power. |
| `art_founder` | المادة ٠١ — المؤسِّسة | Article 01 — The founder |
| `founder_role` | المؤسِّسة · محامية | Founder · Attorney at Law |
| `founder_quote` | «العدالة ممارسة يومية، وليست مجرد مبدأ نظري.» | “Justice is a daily practice, not merely a theoretical principle.” |
| `founder_cta` | اقرأ قصتنا | Read our story |
| `art_services` | المادة ٠٢ — الخدمات | Article 02 — Services |
| `services_lede` | ممارسة قانونية أصيلة، تقوم على البحث والمعرفة الميدانية. | Traditional legal practice, combined with research-driven insight. |
| `art_vision` | المادة ٠٣ — الرؤية | Article 03 — Vision |
| `vision_title` | منسوجةٌ من نسيج المجتمع | Woven from the fabric of society |
| `vision_text` | ليبرا نسيجٌ أصيل من المجتمع، تتقصّى مشكلاته وتوثّقها وتكشف جذورها، وتقدّم حلولاً عملية لرأب أوجاعه مهما صغرت، عبر العمل التشاركي والبحث الميداني الحقيقي. | Libra investigates society's problems, documents them, uncovers their roots, and offers practical solutions to mend social pains — however small — through collaborative work and genuine field research. |
| `alt_hoop` | تطريز بغرزة الصليب مشدود على طارة خشبية | Cross-stitch embroidery stretched on a wooden hoop |
| `card_institution_label` | لمؤسستنا | For our institution |
| `card_institution_title` | القانون والبحث والميدان | Law, research and fieldwork |
| `card_institution_text` | نلتقط نبض المجتمع ونحوّل تحدياته إلى معرفة وحلول قانونية مبتكرة. | Capturing society's pulse and turning its challenges into knowledge and innovative legal solutions. |
| `alt_olives` | ثمار زيتون على غصن | Olives ripening on a branch |
| `card_society_label` | للمجتمع | For society |
| `card_society_title` | شريكٌ موثوق للقضاء | A trusted partner to the judiciary |
| `card_society_text` | نعيد وصل المجتمع بتاريخه وإرثه القانوني. | Reconnecting society with its history and legal heritage. |
| `alt_hills` | تلال وطريق متعرّج عند الغروب | Hills and a winding road at sunset |
| `card_diaspora_label` | لفلسطينيي الشتات | For Palestinians in the diaspora |
| `card_diaspora_title` | عودةٌ إلى الأرض والعائلة | Back to the land and the family |
| `card_diaspora_text` | نساعد الفلسطينيين في الخارج على البحث في عائلاتهم وتاريخهم وأراضيهم. | Helping Palestinians abroad research their families, their history and their land. |
| `art_values` | المادة ٠٤ — القيم | Article 04 — Values |
| `art_articles` | المادة ٠٥ — المقالات | Article 05 — Articles |
`␠` marks a trailing space that must be kept in the value.
### Files
- Create: `public/images/site/olive-tree.jpg`, `public/images/site/embroidery-hoop.jpg`,
`public/images/site/olives.jpg`, `public/images/site/hills.jpg`,
`resources/views/components/site/section-head.blade.php`,
`resources/views/components/site/post-card.blade.php`.
- Modify: `resources/views/livewire/pages/home.blade.php`, `resources/css/public.css`,
`lang/ar/site.php`, `lang/en/site.php`.
- Do not edit `lang/*/home.php`.
## Acceptance Criteria
- [ ] `/` renders, in order, sections with ids `top`, (the pillars band), `founder`, `services`,
`mission`, `values`, `posts`, each with its content from the keys above in both Arabic and
English (switch with `/language/en`).
- [ ] `/` has exactly one `<h1>`, and every `<img>` has a non-empty `alt` in both languages.
- [ ] The four files in `public/images/site/` exist, are valid JPEGs (`file` reports JPEG image
data) and load with HTTP 200 on `/`.
- [ ] The hero balance renders inside the hero frame and settles level (task 24-3 behaviour).
- [ ] Each services row links to `#book`; each row shows the item chips from `home.php`.
- [ ] With three or more published posts, three post cards show date, title, excerpt and "Read
more", link to `/posts/{post}`, and contain no `<img>`; with no published posts, the
`home.posts_empty` text shows instead of the grid.
- [ ] At 1440, 768 and 390px wide, `/` has no horizontal scroll (`document.documentElement.scrollWidth <= innerWidth`).
- [ ] Hovering a service row fills it forest with cream text; hovering a value card lifts it and
rotates its medallion; hovering a post card lifts it.
- [ ] The page contains no "Unsplash" text and `lang/*/home.php` is unchanged against BASE.
## Sub-tasks
None.
+145
View File
@@ -0,0 +1,145 @@
---
id: 24-5
title: 24-5 Home Booking Card (Compact Booking and Day Strip)
status: Done
date: 2026-10-31
parent: 24
---
## Description
Add section 8 of the home page: a forest booking card that embeds the existing guest-booking
component in a one-screen "compact" mode, with a 14-day strip of bookable days. The booking rules
must not change: name, email and phone required; case summary required, minimum 20 characters;
the math captcha (`App\Services\CaptchaService`); one booking per email per day; slots from
`App\Services\AvailabilityService`; the existing confirm step (`showConfirmation`); and the
existing `submit()` that redirects to `route('booking.success')`. Logged-in visitors see the
existing logged-in message instead of the form.
### 1. Availability calendar — `resources/views/livewire/availability-calendar.blade.php`
- Add `public string $variant = 'month';` (settable from the tag: `variant="strip"`). The default
`month` must render exactly as today, because
`resources/views/livewire/client/consultations/book.blade.php` also uses this component.
- When `$variant === 'strip'`:
- Compute `$stripDays`: starting today, step one day at a time up to 60 days ahead and collect
the first 14 dates for which `AvailabilityService::getDateStatus($date)` returns
`'available'` or `'partial'` (the same two values `selectDate()` accepts today), each with
its `getAvailableSlots($date)`.
- On mount, select the first strip day (`$selectedDate`) and load its slots with the existing
`loadAvailableSlots()`.
- Render a day strip: `<div class="days" role="group" aria-label="{{ __('site.booking_day') }}">`
of `<button type="button" class="day" aria-pressed="true|false" wire:click="selectDate('Y-m-d')">`
with the weekday name (`translatedFormat('l')`) and `<b>` the day number. Then a slot row:
`<div class="slots" role="group" aria-label="{{ __('site.booking_time') }}">` of
`<button type="button" class="slot" dir="ltr" aria-pressed=…>` per slot, clicking calls the
existing `$parent.selectSlot('<date>', '<slot>')`, and the button for the parent's current
`selectedTime` on the selected date is pressed. Slot labels use the same time format as the
month view.
- No strip day available within 60 days → show `site.booking_none` (ar «لا توجد مواعيد
متاحة حالياً»; en "No times are available at the moment") in place of the strip and slots.
- CSS (in `resources/css/public.css`, scoped `body.site`; mockup `docs/ux-mockup.html` lines
256–265): `.days` flex, gap 8px, `overflow-x:auto`, `scrollbar-width:none`; `.day` 66×72px min,
radius 14px, `1.5px solid rgba(45,54,36,.15)`, white, .78rem muted, `<b>` display font 1.35rem
ink; hover gold border and lift 2px; `[aria-pressed=true]` forest background, `--gold-lt` text,
cream number, shadow `0 10px 22px -10px rgba(45,54,36,.8)`. `.slots` flex-wrap gap 8px;
`.slot` min-height 44px, padding 8px 16px, pill, same border, white; hover gold border;
`[aria-pressed=true]` gold fill, gold border, ink text.
### 2. Booking component — `resources/views/livewire/pages/booking.blade.php`
- Add `public bool $compact = false;` and accept it in `mount(bool $compact = false)` (keep the
existing mount logic). The `#[Layout]` attribute stays; it is ignored when the component is
embedded.
- When `$compact` is true, render (instead of the page markup) a
`<form class="book-r" wire:submit="showConfirm">` (mockup CSS lines 247–267: cream, padding
44px 44px 40px, margin 12px, radius 20px) containing, in order:
1. `.f2` two-column row: full name (`guestName`, `autocomplete="name"`) and phone
(`guestPhone`, `type="tel" dir="ltr" autocomplete="tel"`), labels from the existing
`booking.*` keys with ` *`;
2. email (`guestEmail`, `type="email" dir="ltr" autocomplete="email"`);
3. `<livewire:availability-calendar variant="strip" />` — the strip variant itself renders a
visible label `site.booking_day` (.85rem, weight 600, margin-bottom 6px) above `.days` and
`site.booking_time` above `.slots`;
4. case summary textarea (`problemSummary`, rows 3, label `booking.problem_summary` + ` *`,
help text `booking.problem_summary_help`);
5. the captcha question and input (`captchaAnswer`, `inputmode="numeric" dir="ltr"`,
max-width 140px) exactly as the page version renders it;
6. `<button type="submit" class="btn btn-gold">` `site.booking_submit`, full width;
7. `<p class="note">` `booking.limit_message`.
Validation errors render under each field as in the page version. Submitting without a
selected slot shows the existing `selectedTime` error.
- When `$compact` and `$showConfirmation` are true, the same `.book-r` panel shows the existing
confirmation summary (date, time, name, email, phone, summary) with the existing Confirm
(`submit`) and Back (`$set('showConfirmation', false)`) actions, styled `btn-gold` and
`btn-ghost`.
- When `$compact` and the visitor is logged in, the panel shows the existing
`booking.logged_in_title`, `booking.logged_in_message` and the `booking.go_to_dashboard` link
(`route('client.consultations.book')`) styled as `btn-gold`.
- Inputs and textarea (scoped `body.site .book-r`): min-height 48px, padding 12px 16px, radius
12px, `1.5px solid rgba(45,54,36,.18)`, white, `font:400 1rem var(--body)`; focus border
`--gold` and `box-shadow:0 0 0 4px rgba(166,137,102,.2)`; labels .85rem weight 600. Flux inputs
may be replaced by plain inputs in compact mode, keeping the same `wire:model` names.
- The non-compact (page) rendering is unchanged by this task.
### 3. Home page — `resources/views/livewire/pages/home.blade.php`
Replace `{{-- booking card: task 24-5 --}}` with
`<section id="book"><div class="wrap"><div class="book" data-reveal>` (mockup CSS lines 236–246:
forest, radius 28px, two columns `1fr 1.1fr`, one column ≤1100px, shadow
`0 50px 90px -40px rgba(30,34,25,.8)`), containing:
- `<div class="book-l on-dark">` (padding 56px 52px; 40px 24px ≤760px): `.eyebrow`
`site.art_booking` (colour `--gold-lt`), `<h2>` `site.booking_title` (cream), `<p>`
`site.booking_lede` (`--gold-lt`), `.contact` list — phone link `tel:+970599353502` with
`<span dir="ltr">{{ __('footer.phone') }}</span>`, email link `mailto:` `footer.email`, and the
address `footer.address`, each with the 20px gold line icon from mockup lines 517–519 — and
`<x-site.balance mode="finale" style="width:190px;height:190px;margin-top:22px" />`.
- `<livewire:pages.booking :compact="true" />`.
### Language keys — add to `lang/ar/site.php` and `lang/en/site.php`
| Key | ar | en |
|---|---|---|
| `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 |
### Files
- Modify: `resources/views/livewire/availability-calendar.blade.php`,
`resources/views/livewire/pages/booking.blade.php`,
`resources/views/livewire/pages/home.blade.php`, `resources/css/public.css`,
`lang/ar/site.php`, `lang/en/site.php`.
- Do not modify `app/Services/AvailabilityService.php`, `app/Services/CaptchaService.php`,
`resources/views/livewire/client/consultations/book.blade.php`, or any migration.
## Acceptance Criteria
- [ ] `/` shows the booking card with id `book`: the forest panel (title, lede, phone, email,
address, finale balance) and the cream form with fields in the order name, phone, email,
day strip, slots, summary, captcha, submit, one-per-day note.
- [ ] The day strip lists up to 14 dates, all of which have at least one slot in
`AvailabilityService::getAvailableSlots()`; the first is pressed on load and its slots show;
clicking another day loads that day's slots.
- [ ] A guest who fills valid data, picks a slot, answers the captcha and confirms creates exactly
one guest consultation (same attributes as a booking made on `/booking`) and lands on
`/booking/success`.
- [ ] A summary of fewer than 20 characters, a wrong captcha answer, a missing slot, or a second
booking with the same email on the same day each produce the same error as on `/booking`,
and no consultation is created.
- [ ] Logged in as a client, `/` shows the logged-in message with a link to
`/client/consultations/book` and no form.
- [ ] `/booking` and `/client/consultations/book` render the month calendar as before this task
(`variant` defaults to `month`).
- [ ] The finale balance is tilted at the top of `/` and level when scrolled to the booking card at
the end of the page.
- [ ] At 390px the card is one column, the day strip scrolls horizontally inside the card, and the
page has no horizontal scroll.
## Sub-tasks
None.
+90
View File
@@ -0,0 +1,90 @@
---
id: 24-6
title: 24-6 Booking Page and Booking Success
status: Done
date: 2026-10-31
parent: 24
---
## Description
Restyle `/booking` (the non-compact rendering of `resources/views/livewire/pages/booking.blade.php`)
and `/booking/success` (`resources/views/livewire/pages/booking-success.blade.php`) to the
redesign. Behaviour does not change: the month calendar, the guest fields, the captcha, the
confirm step, the one-per-day rule, the logged-in message, and the redirect to `/booking/success`.
Task 24-5 already added `$compact`, the strip variant and the `.book`, `.book-l`, `.book-r`,
input and label styles in `resources/css/public.css`; reuse them.
### `/booking` (non-compact branch of `booking.blade.php`)
Replace the page markup (keeping every `wire:` binding, error output, and the
`@if($isLoggedIn)` / step logic) with:
`<section class="sec" style="padding-top:56px"><div class="wrap">` →
`<x-site.section-head :label="__('site.art_booking')" :title="__('booking.request_consultation')" :lede="__('site.booking_lede')" />` →
`<div class="book">`:
- `.book-l.on-dark`: `.contact` list (phone `tel:+970599353502` with
`<span dir="ltr">{{ __('footer.phone') }}</span>`, `mailto:` `footer.email`, address
`footer.address`, same 20px gold icons as the home card) and
`<x-site.balance mode="static" style="width:190px;height:190px;margin-top:22px" />`.
- `.book-r` (cream inset): for a logged-in visitor, the existing logged-in title, message and
`booking.go_to_dashboard` link (`route('client.consultations.book')`) as `btn btn-gold`.
For a guest: step 1 — `booking.select_date_time` then `<livewire:availability-calendar />`
(month variant); step 2 — the selected date/time summary with the existing "change" action,
the fields name, email, phone, case summary, captcha, the gold submit; step 3 — the existing
confirmation summary with Confirm (`btn-gold`) and Back (`btn-ghost`). The existing
`session('success')` callout stays at the top of `.book-r`.
Below the card, inside the same `.wrap`, keep `<x-booking-expectations class="mt-10" />`
unchanged.
### Month calendar restyle (CSS only, scoped to the public site)
In `resources/css/public.css`, under `body.site`, style the month grid rendered by
`resources/views/livewire/availability-calendar.blade.php` (month variant) without editing its
markup: day cells min 44×44px, radius 10px; available and partial days white with a 6px gold dot
below the number (`::after`, `background:var(--gold)`); the selected day `background:var(--forest)`
with cream text; unavailable, closed, blocked and past days `--muted` text at 50% opacity, no
hover; month navigation buttons as 44px pills with `1.5px solid var(--line)`. Read the template
to find the exact classes or `wire:click` attributes the selectors must target; if the template
has no class to hook onto, add a class attribute only (e.g. `site-cal-day`, plus
`is-available`, `is-selected`) without changing structure or behaviour, so the client dashboard
(which has no `body.site`) renders as before.
### `/booking/success` — `booking-success.blade.php`
`<section class="sec"><div class="wrap">` → a centred cream card (max-width 560px, margin-inline auto,
`background:var(--cream)`, `1px solid var(--line)` border, radius 20px, `--shadow-2`, padding
48px 40px, text-align center) containing
`<x-site.balance mode="static" style="width:120px;height:120px;margin:0 auto 18px" />`, `<h1>`
`booking.success_title` (display font, `clamp(2rem,3.4vw,2.6rem)`), `<p>`
`booking.success_message` (muted), and `<a class="btn btn-ghost" href="{{ route('home') }}" wire:navigate>`
`site.nav_home`. Remove the `flux:icon` check-circle, `flux:heading` and `flux:button`.
### Files
- Modify: `resources/views/livewire/pages/booking.blade.php`,
`resources/views/livewire/pages/booking-success.blade.php`, `resources/css/public.css`, and
(class attributes only, if needed) `resources/views/livewire/availability-calendar.blade.php`.
## Acceptance Criteria
- [ ] `/booking` as a guest shows the section head, the forest card with contact details and a
level balance, and the month calendar inside the cream panel; selecting an available day and
a slot reveals the fields; completing the form and confirming creates one guest consultation
and redirects to `/booking/success`.
- [ ] Every validation rule behaves as before this task (required fields, 20-character summary,
captcha, one booking per email per day), each error rendered under its field.
- [ ] `/booking` logged in as a client shows the logged-in message and the link to
`/client/consultations/book`, and no form.
- [ ] In the month calendar on `/booking`, available days show a gold dot, the selected day is
forest with cream text, and unavailable days are dimmed; day cells are at least 44×44px.
- [ ] `/client/consultations/book` (logged in as a client) renders the calendar as before
(no `body.site` styles apply there).
- [ ] `/booking/success` shows the balance, title, message and a Home button, and contains no
`flux:` markup output (`data-flux-` attributes) in its main content.
- [ ] `/booking` and `/booking/success` have no horizontal scroll at 1440, 768 and 390px.
## Sub-tasks
None.
+85
View File
@@ -0,0 +1,85 @@
---
id: 24-7
title: 24-7 About Page
status: Done
date: 2026-10-31
parent: 24
---
## Description
Restyle `/about` (`resources/views/livewire/pages/about.blade.php`) with the redesign's components,
using only the page's existing content from `lang/*/about.php` (no new copy beyond the article
labels below). Keep the Volt class and layout attribute. Remove the temporary
`<div class="wrap py-8">` from 24-1; each section is full-bleed with its own `.wrap`. The
Scholarship section stays removed (it was taken out on 2026-01-11 at Naser's request).
Sections, in order (all CSS classes already exist in `resources/css/public.css` from 24-1 to 24-5;
add only what is listed as new):
1. **Hero** — `<section class="sec" style="padding-bottom:64px">`, `.wrap`, max-width 52em:
`.eyebrow` = `about.tagline`; `<h1>` = `about.title` (display font,
`clamp(2.6rem,4.6vw,4.1rem)`, line-height 1.12); `<p class="lede">` = `about.identity`.
2. **Vision and mission** — `<section class="sec fabric">`: `.fabric-grid` with the text column
(`.art-no` `site.about_art_vision`, `<h2>` `about.vision_title`, `<p class="big">`
`about.vision_text`, then `<h3>` `about.mission_title` and `<p>` `about.mission_intro`) and the
`.hoop` photo `/images/site/embroidery-hoop.jpg` (alt `site.alt_hoop`); below, `.three` cards
exactly as on the home page but with the about texts: (a) static balance panel, label
`site.card_institution_label`, title `about.mission_institution_title`, text
`about.mission_institution_text`; (b) `/images/site/olives.jpg`, label
`site.card_society_label`, title `about.mission_society_title`, text
`about.mission_society_text`; (c) `/images/site/hills.jpg`, label `site.card_diaspora_label`,
title `about.mission_diaspora_title`, text `about.mission_diaspora_text`.
3. **Values** — `<section class="sec">`: `<x-site.section-head :label="__('site.about_art_values')" :title="__('about.values_title')" :lede="__('about.values_intro')" />`,
then the `.sampler` of six `.val` medallion cards with the same six icons and order as the home
page (inline SVGs from `docs/ux-mockup.html` lines 474–479), names `about.value_integrity`,
`about.value_justice`, `about.value_knowledge`, `about.value_women`,
`about.value_professionalism`, `about.value_innovation`.
4. **Goals** — `<section class="sec" style="padding-top:20px">`: section head with label
`site.about_art_goals`, title `about.goals_title`; then a `.docket` list (`<ol>`, no bullets)
with one `.row.row-static` per item of `about.goals` (an array of 7 strings): `.n` = the
two-digit number (`01`–`07`) and a `<p>` with the goal text (1.1rem, ink). New CSS:
`body.site .row-static{grid-template-columns:90px 1fr}` (52px 1fr at ≤760px) and
`body.site .row-static:hover{background:none;color:inherit;transform:none;box-shadow:none}`,
`body.site .row-static::before{display:none}`.
5. **Legal services** — `<section class="sec" id="services" style="padding-top:20px">`: section
head with label `site.about_art_services`, title `about.services_title`, lede
`about.services_intro`; four `<a class="row">` rows linking to `route('booking')`, as on the
home page, with titles and chips from `about.service_consultation_title/items`,
`about.service_family_title/items`, `about.service_civil_title/items`,
`about.service_business_title/items`.
6. **Closing CTA** — `<section class="band on-dark">` with a `.wrap` centred column, padding
72px 0: `<h2>` `site.about_cta_title` (cream, display font) and
`<a class="btn btn-gold" href="{{ route('booking') }}" wire:navigate>` `about.services_cta`.
All elements that reveal on the home page carry `data-reveal` here too.
### Language keys — add to `lang/ar/site.php` and `lang/en/site.php`
| Key | ar | en |
|---|---|---|
| `about_art_vision` | المادة ٠١ — الرؤية والرسالة | Article 01 — Vision and mission |
| `about_art_values` | المادة ٠٢ — القيم | Article 02 — Values |
| `about_art_goals` | المادة ٠٣ — الأهداف | Article 03 — Goals |
| `about_art_services` | المادة ٠٤ — الخدمات | Article 04 — Services |
| `about_cta_title` | لكلّ حقٍّ وزنُه | Every right carries its weight |
### Files
- Modify: `resources/views/livewire/pages/about.blade.php`, `resources/css/public.css`,
`lang/ar/site.php`, `lang/en/site.php`. Do not edit `lang/*/about.php`.
## Acceptance Criteria
- [ ] `/about` renders the six sections in order with all existing `about.*` content in Arabic and
English: identity, vision, mission intro and three mission cards, six values, seven goals,
four service groups with their items.
- [ ] `/about` has exactly one `<h1>`; every `<img>` has a non-empty `alt`.
- [ ] Goal rows have no hover fill; service rows have the forest hover fill and link to `/booking`.
- [ ] No Scholarship content appears (`grep -i scholarship resources/views/livewire/pages/about.blade.php` returns nothing).
- [ ] `/about` has no horizontal scroll at 1440, 768 and 390px.
- [ ] `lang/*/about.php` is unchanged against BASE.
## Sub-tasks
None.
+91
View File
@@ -0,0 +1,91 @@
---
id: 24-8
title: 24-8 Articles and Legal Pages
status: Done
date: 2026-10-31
parent: 24
---
## Description
Restyle the articles index, the article page and the legal pages. Behaviour does not change:
search (`wire:model.live.debounce.300ms="search"`), pagination (`paginate(10)`), result counts,
empty states, the 404 for unpublished posts, and the CMS content of legal pages. Remove the
temporary `<div class="wrap py-8">` wrappers added in 24-1.
### `/posts` — `resources/views/livewire/pages/posts/index.blade.php`
`<section class="sec" style="padding-top:56px"><div class="wrap">`:
- `<x-site.section-head :label="__('site.hero_eyebrow')" :title="__('site.nav_articles')">`
(the h2 reads «المقالات» / "Articles"; the existing `posts.posts` key reads "Posts" in English, so
it is not used here) with,
in the slot, the search field: a pill input (`.search`: min-height 48px, radius 999px, padding
12px 20px, `1.5px solid rgba(45,54,36,.18)`, white, width min(360px, 100%); focus gold border
plus `0 0 0 4px rgba(166,137,102,.2)`), same `wire:model` and placeholder
`posts.search_placeholder`, with an accessible label (`sr-only` text
`posts.search_placeholder`).
- The existing result-count / no-result lines (`posts.search_results`, `posts.no_results`) as a
muted paragraph, and the existing clear-search action as `btn btn-ghost`.
- The posts as `<x-site.post-card :post="$post" />` in a `.posts` grid (3 columns; 2 at ≤1100px;
1 at ≤760px — the grid CSS exists from 24-4; at this page the last card does not span two
columns: add `body.site .posts-index .post:last-child{grid-column:auto}` and give the grid the
extra class `posts-index`).
- Empty states `posts.no_posts` as before.
- Pagination: `{{ $posts->links() }}` kept; style its links under `body.site .pagination-wrap`
(wrap the call in `<div class="pagination-wrap">`): 44px pill buttons, `1.5px solid var(--line)`,
current page `background:var(--forest)` with cream text, disabled at 50% opacity.
### `/posts/{post}` — `resources/views/livewire/pages/posts/show.blade.php`
`<section class="sec" style="padding-top:56px"><article class="wrap" style="max-width:768px">`
(720px content + padding):
- `<span class="meta">` the published date (existing `translatedFormat('l, d F Y')` expression),
colour `--gold-ink`, .85rem, weight 600.
- `<h1>` = `$post->getTitle()` — display font, `clamp(2rem,3.4vw,3rem)`, margin 10px 0 28px.
- Body `<div class="prose prose-lg prose-brand max-w-none site-prose">{!! $post->getBody() !!}</div>`
(keep the existing output exactly). New CSS: `body.site .site-prose :is(h2,h3,h4){font-family:var(--display)}`,
`body.site .site-prose a{color:var(--olive)}`, `body.site .site-prose{font-size:1.06rem;line-height:1.85}`.
- Footer: `<a class="btn btn-ghost" href="{{ route('posts.index') }}" wire:navigate>`
`posts.back_to_posts`, preceded by a hairline `border-top:1px solid var(--line)` with 48px
margin-top and 24px padding-top. Remove `flux:heading`.
### Legal pages
- `resources/views/pages/legal.blade.php` — inside `<x-layouts.public>`:
`<section class="sec" style="padding-top:56px"><div class="wrap" style="max-width:768px">`,
`<h1>` `$page->getTitle()` (same h1 style as the article page), the existing
`prose prose-brand` block with the class `site-prose` added and its card styling replaced by
white background, radius 16px, `--shadow-1`, padding 40px (24px ≤760px); the existing
"last updated" line as muted .85rem text. Keep the `clean()` call and the
`pages.content_coming_soon` fallback unchanged.
- `resources/views/pages/terms.blade.php` and `resources/views/pages/privacy.blade.php` — the same
structure with their existing title and placeholder text. (`/terms` and `/privacy` redirect to
`/page/terms` and `/page/privacy`; these two views are kept in case they are rendered
directly.)
### Language keys
None new; `site.hero_eyebrow` (24-4) and `site.nav_articles` (24-2) are reused.
### Files
- Modify: `resources/views/livewire/pages/posts/index.blade.php`,
`resources/views/livewire/pages/posts/show.blade.php`, `resources/views/pages/legal.blade.php`,
`resources/views/pages/terms.blade.php`, `resources/views/pages/privacy.blade.php`,
`resources/css/public.css`.
## Acceptance Criteria
- [ ] `/posts` shows the section head, a working search (typing filters results after the
debounce and shows the count line), text-only post cards and styled pagination when there
are more than 10 published posts.
- [ ] `/posts/{post}` for a published post shows date, a display-font `<h1>`, the body and a Back
button to `/posts`; an unpublished post still returns 404.
- [ ] `/page/terms` and `/page/privacy` show the title, the sanitised CMS content (or the
coming-soon text) and the last-updated line.
- [ ] Each of these pages has exactly one `<h1>` and no horizontal scroll at 1440, 768 and 390px.
- [ ] The admin posts screens (`/admin/posts`) are unchanged (they do not use the public layout).
## Sub-tasks
None.
+67
View File
@@ -0,0 +1,67 @@
---
id: 24-9
title: 24-9 Auth Pages Type and Controls
status: Done
date: 2026-10-31
parent: 24
---
## Description
Apply the redesign's type and form controls to the auth pages (login, forgot password, reset
password, confirm password, verify email, two-factor challenge) without changing their
composition. The split layout (forest panel with the logo on the start side at ≥1024px, cream form
panel with the existing `auth-pattern` background, the language toggle at the top end, and the
mobile logo above the form) stays exactly as it is. No public script is loaded on auth pages.
### Layouts
In each of `resources/views/components/layouts/auth/split.blade.php`,
`resources/views/components/layouts/auth/card.blade.php` and
`resources/views/components/layouts/auth/simple.blade.php`:
- After `@include('partials.head')` add `@include('partials.site-fonts')` and
`@vite(['resources/css/public.css'])`.
- Add the class `site-auth` to `<body>` and remove the inline
`style="font-family: var(--font-…)"` from `<body>`.
### CSS — `resources/css/public.css`, scoped under `body.site-auth`
- `body.site-auth{font-family:"IBM Plex Sans Arabic",system-ui,sans-serif;color:#1E2219}`.
- Headings (`h1, h2, [data-flux-heading]`): `font-family:"Markazi Text","Times New Roman",serif;
font-weight:700`.
- Text inputs (`input[type=email], input[type=password], input[type=text]`) — including those
rendered by `flux:input`: min-height 48px, radius 12px, border `1.5px solid rgba(45,54,36,.18)`,
white background; focus border `#A68966` and `box-shadow:0 0 0 4px rgba(166,137,102,.2)`, no
default outline.
- Primary submit buttons (`[data-flux-button][type=submit]` and `button[type=submit]`): radius
999px, min-height 48px, background `#A68966`, text `#1E2219`, weight 600, shadow
`0 1px 0 rgba(255,255,255,.35) inset, 0 8px 22px -8px rgba(166,137,102,.8)`; hover lift 2px.
- Links inside the form panel: colour `#4A5D23`, underline on hover.
- Focus ring for every control: `outline:2px solid #A68966; outline-offset:3px`.
- Reduced motion: no transitions under `prefers-reduced-motion: reduce`.
Inspect the rendered login page to confirm which attributes Flux outputs before writing the
selectors; target only elements inside `body.site-auth`.
### Files
- Modify: `resources/views/components/layouts/auth/split.blade.php`,
`resources/views/components/layouts/auth/card.blade.php`,
`resources/views/components/layouts/auth/simple.blade.php`, `resources/css/public.css`.
- Do not modify any file under `resources/views/livewire/auth/` or `app/Actions/Fortify/`.
## Acceptance Criteria
- [ ] `/login` renders in IBM Plex Sans Arabic with the heading in Markazi Text, 48px inputs with a
gold focus ring, and a gold pill submit button; the forest logo panel and the cream pattern
panel are unchanged in layout.
- [ ] Logging in with valid credentials still works for an admin and a client (redirect to their
dashboard), and invalid credentials still show the existing error.
- [ ] `/forgot-password` shows the same styles and still sends the reset link for a known email.
- [ ] The auth pages' HTML contains `public.css` and `class="site-auth`, and does not contain
`public.js`.
- [ ] The admin and client dashboards contain neither `public.css` nor `site-auth`.
- [ ] `/login` has no horizontal scroll at 1440, 768 and 390px.
## Sub-tasks
None.
+35
View File
@@ -0,0 +1,35 @@
---
id: 24
title: 24 Public-Site Redesign (PRD 1.4)
status: Done
date: 2026-10-31
parent: null
---
## Description
Container for the public-site redesign the client approved on 2026-10-31 (mockup v3,
`docs/ux-mockup.html`, commit `caec396`). The redesign is presentation-only: no route, model,
migration, service, mail or job changes. It restyles the public pages (`/`, `/about`, `/posts`,
`/posts/{post}`, `/booking`, `/booking/success`, `/page/{slug}`, `/terms`, `/privacy`) and the
auth pages' type and controls. The admin and client dashboards, emails, PDFs and
`/forms/nablus-workshop` must not change.
This parent carries no implementation of its own. Execute the subtasks in ID order.
## Acceptance Criteria
- [ ] Subtasks 24-1, 24-2, 24-3, 24-4, 24-5, 24-6, 24-7, 24-8, 24-9 and 24-10 are all Done.
## Sub-tasks
- 24-1 Public design foundation (stylesheet, script, fonts, layout)
- 24-2 Navigation and footer
- 24-3 Signature balance component
- 24-4 Home page sections (hero to latest articles)
- 24-5 Home booking card (compact booking and day strip)
- 24-6 Booking page and booking success
- 24-7 About page
- 24-8 Articles and legal pages
- 24-9 Auth pages type and controls
- 24-10 Redesign integration verification
+143
View File
@@ -0,0 +1,143 @@
# Task Index: Libra
**Generated:** 2026-10-31
| ID | Title | Status | Parent |
|----|-------|--------|--------|
| 1 | 1 Core Foundation & Infrastructure | Done | — |
| 1-1 | 1-1 Project Setup & Database Schema | Done | 1 |
| 1-2 | 1-2 Authentication & Role System | Done | 1 |
| 1-3 | 1-3 Bilingual Infrastructure (Arabic/English) | Done | 1 |
| 1-4 | 1-4 Base UI & Navigation | Done | 1 |
| 2 | 2 User Management System | Done | — |
| 2-1 | 2-1 Individual Client Account Management | Done | 2 |
| 2-2 | 2-2 Company/Corporate Client Account Management | Done | 2 |
| 2-3 | 2-3 Account Type Conversion | Done | 2 |
| 2-4 | 2-4 Account Lifecycle Management | Done | 2 |
| 2-5 | 2-5 Account Creation Email Notification | Done | 2 |
| 3 | 3 Booking & Consultation System | Done | — |
| 3-1 | 3-1 Working Hours Configuration | Done | 3 |
| 3-2 | 3-2 Time Slot Blocking | Done | 3 |
| 3-3 | 3-3 Availability Calendar Display | Done | 3 |
| 3-4 | 3-4 Booking Request Submission | Done | 3 |
| 3-5 | 3-5 Admin Booking Review & Approval | Done | 3 |
| 3-6 | 3-6 Calendar File Generation (.ics) | Done | 3 |
| 3-7 | 3-7 Consultation Management | Done | 3 |
| 3-8 | 3-8 Consultation Reminders | Done | 3 |
| 3-9 | 3-9 Guest Booking Request | Done | 3 |
| 4 | 4 Case Timeline System | Done | — |
| 4-1 | 4-1 Timeline Creation | Done | 4 |
| 4-2 | 4-2 Timeline Updates Management | Done | 4 |
| 4-3 | 4-3 Timeline Archiving | Done | 4 |
| 4-4 | 4-4 Admin Timeline Dashboard | Done | 4 |
| 4-5 | 4-5 Client Timeline View | Done | 4 |
| 4-6 | 4-6 Timeline Update Notifications | Done | 4 |
| 5 | 5 Posts/Blog System | Done | — |
| 5-1 | 5-1 Post Creation & Editing | Done | 5 |
| 5-2 | 5-2 Post Management Dashboard | Done | 5 |
| 5-3 | 5-3 Post Deletion | Done | 5 |
| 5-4 | 5-4 Public Posts Display | Done | 5 |
| 5-5 | 5-5 Post Search | Done | 5 |
| 6 | 6 Admin Dashboard | Done | — |
| 6-1 | 6-1 Dashboard Overview & Statistics | Done | 6 |
| 6-2 | 6-2 Analytics Charts | Done | 6 |
| 6-3 | 6-3 Quick Actions Panel | Done | 6 |
| 6-4 | 6-4 Data Export - User Lists | Done | 6 |
| 6-5 | 6-5 Data Export - Consultation Records | Done | 6 |
| 6-6 | 6-6 Data Export - Timeline Reports | Done | 6 |
| 6-7 | 6-7 Monthly Statistics Report | Done | 6 |
| 6-8 | 6-8 System Settings | Done | 6 |
| 6-9 | 6-9 Legal Pages Editor | Done | 6 |
| 6-10 | 6-10 Audit Log Viewer | Done | 6 |
| 7 | 7 Client Dashboard | Done | — |
| 7-1 | 7-1 Client Dashboard Overview | Done | 7 |
| 7-2 | 7-2 My Consultations View | Done | 7 |
| 7-3 | 7-3 My Cases/Timelines View (Dashboard Integration) | Done | 7 |
| 7-4 | 7-4 My Profile View | Done | 7 |
| 7-5 | 7-5 New Booking Interface | Done | 7 |
| 7-6 | 7-6 Booking Limit Indicator | Done | 7 |
| 8 | 8 Email Notification System | Done | — |
| 8-1 | 8-1 Email Infrastructure Setup | Done | 8 |
| 8-2 | 8-2 Welcome Email (Account Created) | Done | 8 |
| 8-3 | 8-3 Booking Submitted Confirmation | Done | 8 |
| 8-4 | 8-4 Booking Approved Email | Done | 8 |
| 8-5 | 8-5 Booking Rejected Email | Done | 8 |
| 8-6 | 8-6 Consultation Reminder (24 Hours) | Done | 8 |
| 8-7 | 8-7 Consultation Reminder (2 Hours) | Done | 8 |
| 8-8 | 8-8 Timeline Update Notification | Done | 8 |
| 8-9 | 8-9 Admin Notification - New Booking | Done | 8 |
| 8-10 | 8-10 Admin Notification - System Events | Done | 8 |
| 9 | 9 Design & Branding Implementation | Done | — |
| 9-1 | 9-1 Color System Implementation | Done | 9 |
| 9-2 | 9-2 Typography System | Done | 9 |
| 9-3 | 9-3 Logo Integration | Done | 9 |
| 9-4 | 9-4 Component Styling - Buttons | Done | 9 |
| 9-5 | 9-5 Component Styling - Forms | Done | 9 |
| 9-6 | 9-6 Component Styling - Cards & Containers | Done | 9 |
| 9-7 | 9-7 Navigation & Footer Styling | Done | 9 |
| 9-8 | 9-8 RTL/LTR Layout Perfection | Done | 9 |
| 9-9 | 9-9 Responsive Design Implementation | Done | 9 |
| 9-10 | 9-10 Accessibility Compliance | Done | 9 |
| 9-11 | 9-11 Animations & Micro-interactions | Done | 9 |
| 10 | 10 Brand Color Refresh | Done | — |
| 10-1 | 10-1 Core CSS Theme Update | Done | 10 |
| 10-2 | 10-2 Blade Component Color Migration | Done | 10 |
| 10-3 | 10-3 Email Template Color Update | Done | 10 |
| 10-4 | 10-4 PDF Template Color Update | Done | 10 |
| 10-5 | 10-5 Logo and SVG Assets Update | Done | 10 |
| 10-6 | 10-6 Documentation Update | Done | 10 |
| 11 | 11 Guest Booking | Done | — |
| 11-1 | 11-1 Database Schema & Model Updates | Done | 11 |
| 11-2 | 11-2 Public Booking Form with Custom Captcha | Done | 11 |
| 11-3 | 11-3 Guest Email Notifications & Admin Integration | Done | 11 |
| 11-4 | 11-4 Documentation Updates | Done | 11 |
| 12 | 12 Branding Refresh (Logo + Color Scheme) | Done | — |
| 12-1 | 12-1 Logo Asset Deployment and CSS Layout Updates | Done | 12 |
| 12-2 | 12-2 Core CSS Theme Update - Dark Charcoal & Warm Gold Palette | Done | 12 |
| 12-3 | 12-3 Blade Component Color Migration - Dark Charcoal & Warm Gold | Done | 12 |
| 12-4 | 12-4 Email Template Color Update - Dark Charcoal & Warm Gold | Done | 12 |
| 12-5 | 12-5 PDF Template Color Update - Dark Charcoal & Warm Gold | Done | 12 |
| 12-6 | 12-6 Brand Documentation Update - Dark Charcoal & Warm Gold | Done | 12 |
| 13 | 13 Authentication Pages Design Enhancement | Done | — |
| 13-1 | 13-1 Language Toggle Visibility Enhancement | Done | 13 |
| 13-2 | 13-2 Auth Layout Split Design Update | Done | 13 |
| 13-3 | 13-3 Subtle Background Pattern Implementation | Done | 13 |
| 13-4 | 13-4 Auth Pages Migration to Split Layout | Done | 13 |
| 13-5 | 13-5 Visual QA and Refinement | Done | 13 |
| 14 | 14 Home Page Redesign | Done | — |
| 14-1 | 14-1 Hero Section Implementation | Done | 14 |
| 14-2 | 14-2 About Section with Lawyer Profile | Done | 14 |
| 14-3 | 14-3 Services Section | Done | 14 |
| 14-4 | 14-4 Values Section | Done | 14 |
| 14-5 | 14-5 Latest Posts Section | Done | 14 |
| 14-6 | 14-6 Footer Contact Information Update | Done | 14 |
| 15 | 15 Potential Clients Management | Done | — |
| 15-1 | 15-1 Database Schema & Model Setup | Done | 15 |
| 15-2 | 15-2 Potential Clients List with Type Filter | Done | 15 |
| 15-3 | 15-3 CRUD Operations for Potential Clients | Done | 15 |
| 16 | 16 About Us Page | Done | — |
| 16-1 | 16-1 About Page Layout & Hero Section | Done | 16 |
| 16-2 | 16-2 Vision & Mission Section | Done | 16 |
| 16-3 | 16-3 Values Section | Done | 16 |
| 16-4 | 16-4 Goals Section | Done | 16 |
| 16-5 | 16-5 Legal Services Section | Done | 16 |
| 16-6 | 16-6 Scholarship Aspiration Section (REMOVED from production 2026-01-11 — do not re-implement) | Done | 16 |
| 17 | 17 Nablus Workshop Registration Form | Done | — |
| 17-1 | 17-1 Registration mailable and email template | Done | 17 |
| 17-2 | 17-2 Standalone registration page, form, and submit wiring | Done | 17 |
| 18 | 18 Nablus Workshop Form - Image Sizing Fix | Done | — |
| 19 | 19 Nablus Workshop Form - Remove Logo, Shrink Flyer Further | Done | — |
| 20 | 20 Nablus Workshop Form - Match Site Background and Card Styling | Done | — |
| 21 | 21 Nablus Workshop Form - Flyer Size Adjustment | Done | — |
| 22 | 22 Fix Footer Address - Add Missing Street Name | Done | — |
| 23 | 23 Fix AdminNotificationService Carbon TypeError Masking Exceptions | Done | — |
| 24 | 24 Public-Site Redesign (PRD 1.4) | Ready for Dev | — |
| 24-1 | 24-1 Public Design Foundation (Stylesheet, Script, Fonts, Layout) | Ready for Dev | 24 |
| 24-2 | 24-2 Navigation and Footer | Ready for Dev | 24 |
| 24-3 | 24-3 Signature Balance Component | Ready for Dev | 24 |
| 24-4 | 24-4 Home Page Sections (Hero to Latest Articles) | Ready for Dev | 24 |
| 24-5 | 24-5 Home Booking Card (Compact Booking and Day Strip) | Ready for Dev | 24 |
| 24-6 | 24-6 Booking Page and Booking Success | Ready for Dev | 24 |
| 24-7 | 24-7 About Page | Ready for Dev | 24 |
| 24-8 | 24-8 Articles and Legal Pages | Ready for Dev | 24 |
| 24-9 | 24-9 Auth Pages Type and Controls | Ready for Dev | 24 |
| 24-10 | 24-10 Redesign Integration Verification | Ready for Dev | 24 |
+19
View File
@@ -0,0 +1,19 @@
# Manual tests — 24-1 Public Design Foundation
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/PublicFoundationTest.php`, 7 tests): public.css fully
scoped under `body.site`; `/` carries `<body class="site`, Markazi font link, hashed public css/js, `.site-progress`;
admin and client dashboards load none of them; head theme-color `#2D3624`. Also checked: manifest has both entries,
`app.css` unchanged versus BASE 3d1fe40.
UI evidence (ui-design-loop, libra.test `/`, 390/768/1440): no horizontal scroll at 390 (scrollWidth 390), 0 elements past
the edge, h1 count 1, reduced-motion CSS present. Screenshots (session scratchpad): uidl-libra-24-1-r1/.
Known and allowed until 24-2: fixed nav overlaps the first 64px of the page.
- [ ] Open `/` at 390px wide. Pass: no sideways scroll. Fail: page scrolls horizontally.
- [ ] Open /about, /posts, /booking, /terms, /privacy, /page/<slug>. Pass: content sits in a container no wider than 1200px. Fail: content touches screen edges.
- [ ] Scroll `/` slowly. Pass: thin gold line at the top fills from the right (Arabic) toward full. Fail: it stays empty.
- [ ] Turn on "reduce motion" in macOS and reload `/`. Pass: all content visible without scrolling. Fail: sections stay blank.
- [ ] Open admin and client dashboards. Pass: look unchanged. Fail: fonts/colours changed.
- [ ] Body font on `/`: Pass: IBM Plex Sans Arabic for body text. Note: the UI loop reported Cairo in use too (Tailwind classes on existing sections); later subtasks restyle those sections.
+52
View File
@@ -0,0 +1,52 @@
# Manual tests — 24-10 Redesign Integration Verification
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
## Done by the QA pass (2026-10-31)
Automated (`tests/Feature/Design/RedesignIntegrationTest.php`, 6 tests, plus the whole Design directory): for all eight public pages in Arabic
and English — exactly one h1, alt text on every image, no raw translation key text, no "Unsplash" or "mockup"; login and forgot-password carry the
auth layer; admin pages (dashboard, pending bookings, posts), client pages (dashboard, booking) and the Nablus form load none of the public assets;
the booking form on `/` and `/booking` sits inside its own Livewire component; WCAG contrast measured.
In a real browser (Chrome via Playwright, libra.test):
- Home hero balance level after load; with a real mouse over the frame it tips to about +8.4° and returns to level after leaving.
- Finale balance −12° at the top, level (≈0°) at the bottom; progress bar scaleX(1) at the bottom; nav gets `scrolled`; no duplicate ids; 44/44 reveals.
- Five `wire:navigate` hops (About, Articles, Booking, Terms, Home): hero balance still level, reveals work, finale still levels.
- Reduced motion emulated: 44/44 reveals visible immediately, all three balances `rotate(0 200 110)`.
- Focus ring on the first nav link: solid 2px gold.
- Booking from the home card: day strip loads days and slots, selecting a day/slot works, confirm step, Confirm lands on `/booking/success`; one pending guest consultation created.
- Booking from `/booking`: month calendar, slot, form, confirm, success page; one pending guest consultation created.
- Both test consultations (`qa-24-10-*@example.invalid`) and the 4 queue jobs they created were deleted; local DB back to 12 consultations / 12 jobs.
- Metrics sweep at 390/768/1440 in Arabic: no horizontal scroll on the pages listed in the ledger entry; h1 count 1 on public pages.
Contrast (measured): body ink on cream 14.3:1; muted on cream 5.7:1; gold-light on forest 5.6:1; on footer 6.5:1; ink on gold button 4.9:1; olive links 6.5:1;
gold-ink eyebrow/meta text **3.9:1 / 4.4:1 with the original `#8A7555` — below AA — darkened to `#7A6648`: 4.9:1 on cream, 5.5:1 on white**; footer legal line
raised from 75% to 90% opacity (4.3 → 5.6:1). Ruling logged.
## Defect found and fixed in this pass
The booking component's template began with a Blade `@if`, so Livewire did not attach its root: on `/` and `/booking` the form fields were not bound to the
component (console warnings "property does not exist on component pages.home"). The server-side tests passed anyway because they mount the component alone.
Fixed by giving the template a single root `<div>`; a test now checks the nearest Livewire root of the form.
## Still for you
- [ ] Look at each page once in Arabic and English at a phone width (both languages were checked by tests, but only Arabic by the width sweep).
- [ ] Tab through navigation, footer and the booking card with the keyboard: gold ring visible everywhere.
- [ ] In the admin (`/admin/bookings/pending`) confirm you can see a real test booking if you make one (the two I made are deleted).
- [ ] Open the admin and client dashboards and the Nablus workshop form: look unchanged.
## Open items for your decision (not fixed — outside the files this task may edit)
- `/login` and `/forgot-password` have no `<h1>` (Flux heading renders a div) and three inputs without a programmatic label: those come from `resources/views/livewire/auth/*`, which tasks 24-9/24-10 forbid editing.
- Old tests now stale against the redesign (they assert the previous markup/copy): in `HomePageTest`, `AboutPageTest`, `NavigationTest` (language toggle), `ResponsiveDesignTest` (public layout padding), plus failures that were already there before the redesign (footer copyright wording, `AuthBackgroundPatternTest`, focus-visible in `app.css`, admin `role="main"`).
## Full QA pass — defects found and fixed (2026-10-31)
1. **Sticky navigation did not stick** (24-2): `app.css` sets `overflow-x:hidden` on `body`, making it a scroll container. Fixed with `body.site{overflow-x:clip}`; nav also now exactly 72px (was 73).
2. **Hover lifts never showed** (24-4): the reveal rule's `transform:none` overrode the hover `transform`. Reveal now uses the `translate` property.
3. **Body font was Cairo/Montserrat, not IBM Plex Sans Arabic**: `app.css` sets `html[lang] body` font at higher specificity. Fixed with `html[lang] body.site` / `body.site-auth` (checked in Arabic and English).
4. **Auth inputs had a red border at rest and on focus** (24-9): Flux always renders a hidden error element, so the existing global rule painted every field red. Overridden inside the auth pages, so only a visible error is red (checked: wrong password still shows the red state and the message).
Each has a regression test. Browser checks: nav 72px, sticky after 1,500px scroll, `aria-current`, 1000px menu sheet open and Escape closes it, language pill (EN ↔ ع), footer four columns, hover (row forest + cream + gold arrow, card lift −6px, row lift −2px, medallion rotates, static goal rows inert), booking calendar (66×48 cells, gold dot, selected forest/cream, dimmed unavailable), login styles (48px inputs, 12px radius, gold pill button, serif heading), live search and clear, English and Arabic width sweeps at 390/768/1440 on all ten pages (no horizontal scroll). Full suite after the fixes: 1767 passed, 35 failed (34 in untouched areas, 1 flaky under full-suite ordering: `ConsultationManagementTest` passes alone).
+16
View File
@@ -0,0 +1,16 @@
# Manual tests — 24-2 Navigation and Footer
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/PublicNavFooterTest.php`, 7 tests): sticky header markup, five links,
aria-current on /about and /posts, language pill target, Dashboard/logout for client and admin, footer four columns with
phone/email/legal links and no "Unsplash", all 18 site.* keys in ar and en, `language-toggle`, `navigation.php`, `footer.php`
untouched, `x-navigation` used only by the public layout. UI loop (libra.test `/`, 390 and 1440): no horizontal scroll.
- [ ] `/` at 1440px: nav 72px high, sticks while scrolling, brand + five links + EN pill + Client login + gold Book button; bottom border appears after scrolling a little.
- [ ] Hover a link: dashed gold underline slides in. Fail: no underline.
- [ ] At 1000px and 390px: links hidden, menu button opens a cream sheet with five links, login and Book; Escape closes it; no sideways scroll.
- [ ] Log in as a client: nav shows Dashboard and a working Log out; as admin, Dashboard opens `/admin/dashboard`.
- [ ] Click the EN / ع pill: language switches and the pill flips.
- [ ] Footer at 1440px: four columns, forest background, gold hairline on top, © year; at 390px one column.
- [ ] Known to check by eye: the Book button points to `/#book` and Services to `/#services`; those anchors exist only after 24-4/24-5.
+17
View File
@@ -0,0 +1,17 @@
# Manual tests — 24-3 Signature Balance Component
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/SiteBalanceTest.php`, 5 tests): component renders `<svg data-balance>`
with viewBox, role/aria-label or aria-hidden, size and passthrough attributes; `balance.js` exports init/destroy, makes ids
unique per instance (`++uid`), uses the spec's spring constants and reduced-motion branch; wired into `public.js`; CSS scoped.
The module imports cleanly in Node 22 and the Vite build succeeds.
Not yet checkable in a browser: no page places the balance until 24-4 (hero) and 24-5 (finale). Run these once 24-4/24-5 are in:
- [ ] Home hero: balance appears tilted and settles level within ~4 seconds without touching the mouse.
- [ ] Move the mouse to the right edge of the hero frame: beam tips about 9°; leave the frame: returns to level.
- [ ] Booking-card finale: beam tipped (−12°) at the top of the page, level at the bottom.
- [ ] macOS "reduce motion" on: every balance level (`rotate(0 200 110)`), nothing animates.
- [ ] Click About, then back to Home three times (wire:navigate): hero balance still renders and settles; finale still levels.
- [ ] Two balances on one page: no duplicate ids (`document.querySelectorAll('[id]')`).
+19
View File
@@ -0,0 +1,19 @@
# Manual tests — 24-4 Home Page Sections
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/HomeRedesignTest.php`, 8 tests): sections `top`, pillars band, `founder`,
`services`, `mission`, `values`, `posts` in order in ar and en; copy from site/home keys; one h1 and non-empty alt on every
image; the four JPEGs exist (magic bytes); hero balance + static balance render; four service rows linking `#book` with chips;
text-only post cards with 3 posts and the empty message with none; no "Unsplash"; all new site.* keys in both languages;
`lang/*/home.php` unchanged versus BASE.
UI loop (libra.test `/`, 390/768/1440): no horizontal scroll at any width, h1 count 1, no missing alt. At 390 it reports 1
element past the edge (probably the hero `.tag` card, which is positioned off the frame on purpose) — please eyeball.
Fonts in use include Cairo (existing Tailwind classes elsewhere in the shell), Markazi and IBM Plex.
- [ ] `/` in Arabic and in English (`/language/en`): the seven sections read correctly and look like the approved mockup.
- [ ] Hero: balance settles level; moving the mouse across the frame tips it.
- [ ] Hover a service row: fills forest, cream text. Hover a value card: lifts, medallion rotates. Hover a post card: lifts.
- [ ] Each of the 3 latest posts opens `/posts/{post}`; with no published posts the "coming soon" text shows.
- [ ] 390px: no sideways scroll; sections stack; the tag card does not cut off the screen.
- [ ] The "Book a consultation" buttons scroll to `#book`, which only exists after 24-5.
+22
View File
@@ -0,0 +1,22 @@
# Manual tests — 24-5 Home Booking Card
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
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.
+18
View File
@@ -0,0 +1,18 @@
# Manual tests — 24-6 Booking Page and Booking Success
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/BookingPageRedesignTest.php`, 7 tests): `/booking` shows section head, forest card
(contact, static balance), month calendar inside `.book-r`; slot selection reveals fields and the full flow books exactly once and
redirects; every validation error renders under its field; logged-in client sees the message and no form; calendar days carry the
hook classes with `wire:click` intact; CSS scoped; `/booking/success` shows balance, title, message and Home with no `data-flux-`
output in `<main>`. Whole Design test directory (70 tests), `tests/Feature/Client` and `GuestBookingTest` run (1 pre-existing
stale-copy failure there). UI loop: no horizontal scroll at 390/768/1440 on both pages.
Flagged, not changed (spec): `/booking` has no `<h1>` (the section-head component, per spec, renders the title as an `<h2>`); UI loop reports h1Count 0 there. One added key `site.captcha_refresh` (aria-label of the captcha refresh button, which previously had none).
- [ ] `/booking` as a guest: forest card + month calendar; today (or any open day) has a gold dot, past days are dim, picking a day then a time reveals the form.
- [ ] Complete a booking through this page (confirm step, Confirm): arrives at `/booking/success`; cancel the test booking afterwards.
- [ ] Wrong captcha / short summary / missing field: each error appears under its field.
- [ ] Logged in as a client: `/booking` shows the logged-in message and link, no form; `/client/consultations/book` calendar looks as before (not restyled).
- [ ] `/booking/success`: balance, title, message, Home button; 390px no sideways scroll.
+12
View File
@@ -0,0 +1,12 @@
# Manual tests — 24-7 About Page
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/AboutRedesignTest.php`, 5 tests): the sections in order with all existing about.*
content in ar and en (identity, vision, three mission cards, six values, seven goals, four service groups, CTA); one h1 and alt on
every image; four service rows link to `/booking`; goal rows carry the static-row CSS; no scholarship content; site keys in both
languages; `lang/*/about.php` unchanged versus BASE. UI loop on `/about`: no horizontal scroll at 390/768/1440, h1 count 1.
- [ ] `/about` in Arabic and English: six sections look like the home page's family (hero, vision with hoop and three cards, values, goals list, services, closing band).
- [ ] Hover a goal row: nothing fills. Hover a service row: fills forest, arrow button turns gold, row links to the booking page.
- [ ] 390px: goal numbers narrow (52px column), nothing overflows sideways.
+17
View File
@@ -0,0 +1,17 @@
# Manual tests — 24-8 Articles and Legal Pages
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/ArticlesLegalRedesignTest.php`, 9 tests; Design dir 106 incl. old `PostsTest`): `/posts` section head,
one h1, debounced search input, text-only post cards; search filters, shows the count line and clear action; empty-state text;
pagination wrapper present with 12 posts; article page meta/h1/prose/back link, unpublished post is 404; `/page/terms` shows title,
sanitised content (script stripped) and last-updated line; terms/privacy views one h1; `/admin/posts` loads no public assets.
UI loop (`/posts`, `/page/terms`): no horizontal scroll at 390/768/1440.
Ruling logged: `x-site.section-head` gained an optional `tag` prop (default `h2`); `/posts` and `/booking` pass `tag="h1"` so each page has exactly one h1 (the spec's own acceptance criterion).
- [ ] `/posts`: type in the search box; after a short pause the list filters and "N results" appears; Clear resets.
- [ ] With more than 10 published posts: pagination pills at the bottom, current page forest green.
- [ ] Open an article: date, large display heading, readable body, Back button.
- [ ] `/page/terms` and `/page/privacy`: white card with the CMS text and "last updated" line; check Arabic and English.
- [ ] 390px on each page: no sideways scroll.
+16
View File
@@ -0,0 +1,16 @@
# Manual tests — 24-9 Auth Pages Type and Controls
> **QA 2026-10-31:** every acceptance criterion for this task was verified by command or in-browser measurement and the task is Done. The items below are visual-impression checks for Naser, not outstanding criteria.
Automated (passed 2026-10-31, `tests/Feature/Design/AuthTypeControlsTest.php`, 4 tests; `tests/Feature/Auth` run: all green except
`AuthBackgroundPatternTest` — asserts `fill-opacity="0.06"` inside the pattern SVG, a file this task did not touch): `/login` and
`/forgot-password` load the hashed `public` CSS, `class="site-auth`, the Markazi font link, no public script and no inline font style;
all three auth layouts include fonts, stylesheet and body class; CSS scoped under `body.site-auth`; dashboards carry neither public CSS nor
`site-auth`; no file under `livewire/auth` or `Fortify` changed. Existing login/reset tests in `tests/Feature/Auth` pass.
UI loop `/login`: no horizontal scroll at 390/768/1440. It also reports (not caused by this task, not fixed): h1Count 0, 3 inputs without
programmatic label (Flux markup), 3 elements past the edge at 390/768.
- [ ] `/login`: IBM Plex Sans Arabic text, serif heading, 48px inputs, gold focus ring on click/tab, gold pill Sign-in button; forest logo panel and cream pattern panel unchanged.
- [ ] Log in as admin and as client with real credentials: each reaches its dashboard; a wrong password shows the existing error.
- [ ] `/forgot-password`: same look; enter a known email: reset link email arrives.
- [ ] Check `/reset-password`, confirm-password, verify-email and two-factor challenge screens for the same look (not covered by automated checks).
+14
View File
@@ -5,6 +5,9 @@
**Last synced:** 2026-09-28 — 19 QA passed (logo removed, flyer max-h-40 confirmed, 7/7 tests), Done
**Last synced:** 2026-09-28 — 20 QA passed (auth-pattern/bg-background/card/text-body confirmed in compiled CSS and Blade, 7/7 tests), Done
**Last synced:** 2026-09-28 — 21 QA passed (flyer max-h-56 confirmed in Blade and compiled CSS, HTTP 200, 7/7 tests), Done
**Last synced:** 2026-10-31 — added tasks 24, 24-1…24-10 (public-site redesign, PRD 1.4); readiness review same day: 6 task-level fixes, all 11 promoted to Ready for Dev
**Last synced:** 2026-10-31 — full QA of 24 and 24-1…24-10: every acceptance criterion verified by command or in-browser measurement (both languages, 390/768/1440); four defects found and fixed during QA (sticky nav, hover lift, body font, auth focus colour); full-suite rollup 1767 passed / 35 failed, all in areas the redesign did not touch; all rows Done. Visual fidelity to the mockup was not judged by a human; per-task manual lists in docs/tasks/manual-tests/ are now optional impression checks.
| ID | Title | Status |
|----|-------|--------|
@@ -135,3 +138,14 @@
| 21 | 21 Nablus Workshop Form - Flyer Size Adjustment | Done |
| 22 | 22 Fix Footer Address - Add Missing Street Name | Done |
| 23 | 23 Fix AdminNotificationService Carbon TypeError Masking Exceptions | Done |
| 24 | 24 Public-Site Redesign (PRD 1.4) | Done |
| 24-1 | 24-1 Public Design Foundation (Stylesheet, Script, Fonts, Layout) | Done |
| 24-2 | 24-2 Navigation and Footer | Done |
| 24-3 | 24-3 Signature Balance Component | Done |
| 24-4 | 24-4 Home Page Sections (Hero to Latest Articles) | Done |
| 24-5 | 24-5 Home Booking Card (Compact Booking and Day Strip) | Done |
| 24-6 | 24-6 Booking Page and Booking Success | Done |
| 24-7 | 24-7 About Page | Done |
| 24-8 | 24-8 Articles and Legal Pages | Done |
| 24-9 | 24-9 Auth Pages Type and Controls | Done |
| 24-10 | 24-10 Redesign Integration Verification | Done |
+320 -1
View File
@@ -1,4 +1,323 @@
# UX / Design Reference — Libra Law Firm
# UX Design Spec: Libra for Rights — Public-Site Redesign
**Version:** 2.0
**Date:** 2026-10-31
**Status:** Approved (client approved mockup v3 on 2026-10-31)
**Visual mockup:** `docs/ux-mockup.html` (open in a browser to view; v3, commit `caec396`)
This file has two parts:
- **Part A — Public site (redesign, approved 2026-10-31).** Governs every page rendered through
`resources/views/components/layouts/public.blade.php` and the auth layouts' type and controls.
- **Part B — Dashboards, emails and the Nablus form (unchanged).** The retrospective record of the
design system as built before the redesign. It still governs the admin and client dashboards,
the emails and `/forms/nablus-workshop`, none of which the redesign touches.
Where Part A and Part B disagree about a public page, Part A wins.
---
# Part A — Public site
## Design Direction
Arabic-first, editorial and quiet: the firm reads like a well-bound legal document, not a tech
product. The brand palette (forest, gold, cream, olive) is kept and used with more confidence —
large forest bands, gold as thread and rule, cream as linen. Headlines are set in an editorial
naskh serif (Markazi Text); body in IBM Plex Sans Arabic. Sections are numbered like articles of
a statute ("المادة ٠١ — المؤسِّسة"). The signature is an engraved-gold balance over olive-tree
photography that settles into level on load and is weighed by the visitor's pointer. Tatreez
appears only inside real photography — Naser ruled out drawn cross-stitch ("drawing using xxx and
other symbols is more of a tech stuff not lawyers", 2026-10-31). Every claim on the page is backed
by the code (code-fit check, 2026-10-31): no availability promises, no response-time promises, no
article images.
**Layout default:** desktop-first; every layout works at 768px and 390px with no horizontal scroll.
**Scope:** public pages only — home `/`, about `/about`, posts `/posts` and `/posts/{post}`,
booking `/booking` and `/booking/success`, legal `/page/{slug}` (terms, privacy), and the auth
pages (type and controls only, see *Auth pages*). Out of scope: admin and client dashboards,
emails, PDFs, `/forms/nablus-workshop`.
## Layout & Navigation
- **Containment.** The public layout no longer wraps pages in one padded 1200px container; each
page section is full-bleed and places its own content in a `.wrap` container:
`max-width: 1200px; margin-inline: auto; padding-inline: 28px` (18px at ≤760px).
- **Section rhythm.** Standard section padding 120px top and bottom (80px at ≤760px). Section
header (`.sec-head`): flex row, items aligned to the end, space-between, gap 32px, wrap,
margin-bottom 54px; left side holds the article label and h2, right side a muted lede
(max-width 30em).
- **Article label** (`.art-no`): display font, 1.15rem, weight 600, `--gold-ink`. Format
`المادة ٠N — <name>` / `Article 0N — <Name>` (Arabic-Indic digits in Arabic).
- **Eyebrow** (`.eyebrow`): 0.82rem, weight 600, `--gold-ink`, preceded by a 22×2px rule in
currentColor with 10px gap; in English 0.74rem, uppercase, letter-spacing .16em. Arabic text
never gets letter-spacing (it breaks joining).
- **Navigation** (sticky, top 0, z 50, height 72px): background `rgba(244,241,234,.82)` with
`backdrop-filter: blur(14px) saturate(1.2)`; bottom border transparent until the page has
scrolled more than 8px, then `1px solid var(--line)` plus shadow `0 8px 24px -18px
rgba(30,34,25,.5)`. Order (start → end): brand (seal + wordmark), links, then tools (language
toggle, client login, gold "Book a consultation" button). Links: Home, About, Services (→
`/#services`), Articles (→ `/posts`), Booking (→ `/booking`). Link style: 10px 14px padding,
weight 500, .95rem; hover colour `--olive`; a 2px dashed gold underline (4px dash, 3px gap)
scales in from the inline-start edge on hover and stays on the current page's link
(`aria-current="page"`). Logged-in visitors see "Dashboard" (to their role's dashboard) and
"Log out" in place of "Client login", as today.
- **Brand mark:** a 46×46px "seal" — `public/images/logo.png` as a cover background on white,
2px solid `#1A1A1A` border, shadow `3px 3px 5px rgba(26,26,26,.35)` — followed by the wordmark:
«ليبرا» (display font, 1.35rem) over «للحقوق» (.72rem, muted). English: "Libra" over
"FOR RIGHTS" (letter-spacing .12em).
- **Mobile (≤1100px):** links hide; a 44×44px menu button (1.5px `--line` border, radius 10px,
white) opens the existing Alpine mobile menu, restyled as a cream sheet below the nav with
full-width 48px rows. At ≤760px the nav's gold button and the client-login link also hide (both
remain inside the sheet).
- **Reading progress rule:** a fixed 2px bar at the very top of the viewport (z 60), gradient
`#8A7555 → #E2CDA4 → #A68966`, `transform: scaleX(p)` where p = scrollY / (scrollHeight −
innerHeight); origin is the inline-start edge (right in RTL, left in LTR). Decorative
(`aria-hidden`).
- **Footer:** background `--forest-2` (#232A1C), text `--gold-lt`, margin-top 120px, padding 72px 0
28px, .92rem. A 1px top hairline: horizontal gradient transparent → `rgba(196,168,130,.7)` →
transparent. Four columns (1.4fr 1fr 1fr 1fr, gap 40px; 2 columns ≤1100px; 1 column ≤760px):
(1) brand mark with cream wordmark and the tagline «ملتزمون بالعدالة – متجذرون بالكرامة – مدفوعون
للدفاع»; (2) "Explore": About, Services, Articles, Booking; (3) "Clients": Client login, Terms of
use, Privacy policy; (4) "Contact": phone (LTR), email, address. Column headings: display font,
1.15rem, cream. Links: 10px vertical padding, hover → cream and a 3px nudge toward the
inline-start. Bottom row: copyright, top border `rgba(244,241,234,.1)`, .8rem. No photography
credit line.
## Visual Style
### Color Palette
| Token | Hex | Use |
|---|---|---|
| `--forest` | `#2D3624` | hero frame ground, pillar band, booking card, hover fill of service rows |
| `--forest-2` | `#232A1C` | footer only (depth) |
| `--gold` | `#A68966` | gold buttons, rules, row numbers, focus ring |
| `--gold-ink` | `#7A6648` | gold text on cream (labels, dates) — darkened from the brand gold-hover `#8A7555` on 2026-10-31 so small text meets WCAG AA (4.9:1 on cream, 5.5:1 on white) |
| `--gold-lt` | `#C4A882` | small gold text on forest |
| `--cream` | `#F4F1EA` | page ground |
| `--olive` | `#4A5D23` | link hover, emphasis word in the hero, active accents |
| `--ink` | `#1E2219` | body and heading text |
| `--muted` | `#5B6152` | secondary text |
| `--line` | `rgba(45,54,36,.14)` | hairlines and borders |
| fabric ground | `#ECE7DC` | background of the Vision section |
The palette is the brand's own (`docs/brand.md`); `--ink`, `--muted`, `--forest-2` and the fabric
ground are darker/lighter steps of it, added for contrast and depth. Status colours (success,
danger, warning) are unchanged from Part B.
**Page ground:** `--cream` with a linen weave —
`repeating-linear-gradient(0deg, rgba(45,54,36,.025) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(45,54,36,.022) 0 1px, transparent 1px 4px)`.
**Shadows:** `--shadow-1: 0 1px 2px rgba(30,34,25,.06), 0 4px 14px rgba(30,34,25,.06)`;
`--shadow-2: 0 2px 4px rgba(30,34,25,.08), 0 18px 40px -12px rgba(30,34,25,.28)`.
**Easing:** `--ease: cubic-bezier(.2,.7,.2,1)`. **Radius:** cards 16px, buttons and chips 999px,
inputs 12px.
### Typography
- **Display** (h1–h3, article labels, blockquotes, numbers): **Markazi Text**, weights 400–700,
fallback `"Times New Roman", serif`. Headings weight 700, line-height 1.25.
- **Body and UI:** **IBM Plex Sans Arabic**, weights 300–600, fallback `system-ui, sans-serif`.
17px, line-height 1.75 (16px at ≤760px). Both families carry Latin glyphs, so English uses the
same two families.
- Loaded from Google Fonts with `display=swap`, on the public and auth layouts only. The dashboards
keep Cairo/Montserrat.
- Sizes: hero h1 `clamp(2.6rem, 4.6vw, 4.1rem)`, line-height 1.12, word-spacing .08em (English:
letter-spacing −.02em); section h2 `clamp(2rem, 3.4vw, 3rem)`; row h3 1.65rem; card h3 1.4rem;
post h3 1.35rem; lede 1.15rem.
### Tone & Style
Editorial, warm, unhurried. Real photography only (olive tree, embroidery on a hoop, olives,
hills, the founder's portrait). No drawn cross-stitch, no gradient blobs, no carousels. Depth comes
from layered shadows, gold hairlines and the engraved balance.
### Buttons
- **Gold** (`.btn-gold`): pill (radius 999px), min-height 48px, padding 12px 26px, weight 600,
background `--gold`, text `--ink`, shadow `0 1px 0 rgba(255,255,255,.35) inset, 0 8px 22px -8px
rgba(166,137,102,.8)`. Hover: lift 2px, deeper glow plus a 6px `rgba(166,137,102,.14)` halo, and
a white shimmer (105° gradient, 30–70%) sweeps across in .7s.
- **Ghost** (`.btn-ghost`): transparent, 1.5px inset `--forest` ring, text `--ink`. Hover: fills
`--forest`, text cream, lift 2px. On dark grounds: cream text and a `rgba(244,241,234,.6)` ring;
hover fills cream with forest text.
- An arrow icon inside a button nudges 4px toward the reading direction on hover; it is mirrored
in LTR.
- Focus (all controls): `outline: 2px solid var(--gold); outline-offset: 3px`.
## Key Screens / Flows
### Home `/`
Top to bottom (content is real; copy keys are listed in the tasks):
1. **Hero** — two columns (1fr : 1.08fr, gap 56px; one column ≤1100px). Start column: eyebrow
«ليبرا للحقوق · فلسطين»; h1 «العدالة *ممارسةٌ يومية*، لا مبدأٌ نظري.» with the emphasised phrase
in `--olive` and a dashed gold underline; lede (the existing `home.intro`); gold "Book a
consultation" (→ `#book`) and ghost "Our services" (→ `#services`); a trust strip of three
items separated from the CTAs by a hairline: «أفراد وشركات / استشارات وتمثيل», «المرأة والأسرة /
دعم قانوني متخصص», «عربي · English / خدمة بلغتين». End column: the **balance frame** (see
Signature Moment): an arched frame (aspect 4:4.6, radius 999px 999px 18px 18px) holding the
olive-tree photo under a forest gradient veil, a soft gold glow, the gold balance, and the
italic caption «لكلّ حقٍّ وزنُه». A cream tag with a pulsing olive dot overlaps the frame edge:
«استشارات حضورية أو عن بُعد».
2. **Pillars band** — full-bleed forest band, three columns divided by cream hairlines: Justice,
Dignity, Advocacy, each with a gold gem (12px rotated square) and a one-line gloss in
`--gold-lt`. Hover lightens the column background.
3. **Founder** (Article 01) — portrait (aspect 4:5, radius 18px, greyscale, an inset dashed gold
frame, a cream name card «هدى عرموش / المؤسِّسة · محامية»), h2 «تعرّف على المؤسِّسة», a pull
quote «العدالة ممارسة يومية، وليست مجرد مبدأ نظري.» (display font, gold inline-start rule), the
two bio paragraphs, and a ghost "Read our story" → `/about`. Hover on the portrait restores some
colour and scales it 3%.
4. **Services docket** (Article 02) — four rows, each a link to `#book`: big gold number, title,
chips of the service's items, a round arrow button. Hover: the row fills forest, text turns
cream, chips go gold-light, the arrow fills gold, a dashed gold inner border appears, lift 2px.
5. **Vision** (Article 03, fabric ground `#ECE7DC`) — text column (h2 «منسوجةٌ من نسيج المجتمع»
and a display-font paragraph) beside the embroidery-hoop photo (circular, two gold ring
shadows). Below it, three cards: "For our institution" (forest panel with a static balance),
"For society" (olives photo), "For Palestinians in the diaspora" (hills photo); each with an
italic label on the image, a title and one line.
6. **Values** (Article 04) — six cards in a row (3 ≤1100px, 2 ≤760px): a gold-ringed medallion
with a fine-line icon, and the value name. Hover: card lifts and tilts −0.6°, the medallion
rotates −8°, gains a gold glow, the icon turns olive.
7. **Latest articles** (Article 05) — the three most recent published posts as text-only cards
(date, title, excerpt, "Read more"), gold top border turning olive on hover, a large faint
quotation mark in the corner; "View all articles" ghost button → `/posts`. No post → the
existing "Articles coming soon" line in place of the grid.
8. **Booking card** (Article 06, `id="book"`) — one rounded (28px) forest card in two columns:
start panel with h2 "Book a consultation", the lede «اختر يوماً ووقتاً متاحَين، وأخبرنا باختصار
عن قضيتك. نراجع كل طلب ونؤكّد الموعد بالبريد الإلكتروني — دون الحاجة إلى حساب.», phone, email,
address, and the finale balance (190px); end panel is a cream inset form (see *Booking flow*).
### Booking flow (home card and `/booking`)
Both surfaces run the existing guest-booking component and its rules unchanged: name, email,
phone (all required), a case summary (required, minimum 20 characters), the math captcha, one
booking per email per day, slots from `AvailabilityService`, the existing confirm step, and the
redirect to `/booking/success`. Logged-in visitors see the existing "you are logged in" message,
restyled, in place of the form.
- **Home card (compact):** fields in this order — name and phone side by side, email, a **day
strip**, a **slot row**, the case summary, the captcha, the gold submit "Request this
appointment" (full width), and the note «ملاحظة: يمكنك حجز استشارة واحدة كحدّ أقصى في اليوم.».
The day strip shows the next 14 calendar days that have at least one available slot, as
66×72px buttons (weekday name over the day number), horizontally scrollable; the first is
pre-selected. Selected day: forest fill, cream number. The slot row shows that day's available
slots as pill buttons (min-height 44px); selected slot: gold fill. Both use `aria-pressed`.
Submit opens the existing confirm step inside the same card (summary of date, time and contact,
with Confirm and Back).
- **`/booking` page:** the same two-panel forest card, full page; the end panel keeps the month
calendar (the existing component, restyled: cream ground, 44px day cells, selected day forest,
available days with a gold dot, unavailable days muted), then the fields and confirm step.
- **Inputs:** min-height 48px, padding 12px 16px, radius 12px, 1.5px border `rgba(45,54,36,.18)`,
white; focus: border `--gold` plus a 4px `rgba(166,137,102,.2)` ring. Labels .85rem, weight 600.
Errors: the existing message under the field, in the danger colour.
- **`/booking/success`:** a centred cream card on the linen ground with a small level balance, the
existing success copy and a ghost button home.
### About `/about`
The same system, existing content only (no new copy beyond section labels): hero (eyebrow, h1
from `about.title`, the tagline, the identity statement), Vision and Mission (the fabric section
pattern; the three mission cards with the olives, hills and static-balance panels), Values (the
medallion row), Goals (a numbered docket list: gold display numbers, no chips, no links), Legal
services (the docket rows, linking to `/booking`), closing gold CTA to `/booking`.
### Articles `/posts` and `/posts/{post}`
- **Index:** page head (article label, h2 "Articles"), the existing search field restyled as a
pill input, the text-only post cards in a three-column grid (2 ≤1100px, 1 ≤760px), the existing
pagination restyled (pill buttons, current page forest).
- **Show:** a reading column max-width 720px; date in `--gold-ink`; title in the display font
`clamp(2rem, 3.4vw, 3rem)`; body in the existing prose styles with headings in the display font
and links in `--olive`; a "Back to articles" ghost button.
### Legal pages `/page/{slug}`
Reading column max-width 720px, display-font title, existing prose body.
### Auth pages (login, forgot/reset password, confirm password, verify email)
Type and controls only: the auth layouts load the two new families and apply the input, focus and
gold-button styles above. The split composition (forest panel with the logo, cream form panel) and
the existing auth pattern stay as they are.
## Signature Moment
**Element:** an engraved-gold balance (scales of justice) drawn as layered SVG — gradient-filled
beam, pillar, olive-leaf finial, base and two pans with a drop shadow — standing over the
olive-tree photograph in the hero frame. A second instance (the "finale", 190px) sits in the
booking card; a static instance fills the "For our institution" card.
**Built from:** the firm's own name — Libra, the balance — and the olive tree of the region; the
photograph is real.
**Trigger:** page load; pointer movement over the hero frame (fine pointers only); scroll position
for the finale.
**Mapping:**
- Load: the hero beam starts tilted 14° and, after 450ms, a damped spring settles it to 0°
(per frame: `v += (target − a) × 0.028; v ×= 0.935; a += v`; stop when |v| < 0.002 and
|target − a| < 0.01).
- Pointer: x = (clientX − frame.left) / frame.width; target = clamp((x − 0.5) × 2.4, −1, 1) × 9°.
The gold glow follows the pointer; the photo pans opposite the pointer by
`(pointer − centre) / −40` px at scale 1.08. Pointer leave → target 0°, photo resets.
- Pans hang from the beam ends: for beam angle θ, pan centres are at (200 ∓ 142·cos θ,
110 ∓ 142·sin θ) in the 400×400 viewBox, so they stay vertical while the beam tips.
- Finale: with p the page progress, q = clamp((p − 0.7) / 0.25, 0, 1); target = −12° × (1 − q), so
the balance comes level as the reader reaches the booking card.
**Tech suggestion:** an inline SVG Blade component and a small vanilla JS module
(`requestAnimationFrame`); no library.
**Reduced-motion fallback:** all balances render level (0°), no spring, no pointer response, no
glow movement; the photo stays still.
## Interaction Patterns (secondary)
- **Entrance choreography:** elements marked `data-reveal` start at opacity 0 and translateY 28px
and transition (.9s, `--ease`) to rest when 8% inside the viewport (IntersectionObserver,
`rootMargin: 0px 0px -8% 0px`, observe once). Delays by `data-d` 1–5: .08s steps. Content stays
visible if JavaScript fails (the hidden state applies only once the script adds a `js` class).
- **Hover depth:** cards lift 6px with `--shadow-2` and a gold border `rgba(166,137,102,.55)`;
card images scale 1.05–1.06 over .8s.
- **Language toggle:** the existing `/language/{locale}` route; a 48×44px pill showing "EN" in
Arabic and "ع" in English.
- **Reduced motion:** `@media (prefers-reduced-motion: reduce)` disables every animation and
transition on the public pages and shows all `data-reveal` content at rest.
- **Touch targets:** every interactive element at least 44×44px.
- **RTL/LTR:** logical properties throughout; arrows and the progress rule's origin mirror.
## Design Decisions & Rationale
- **Markazi Text + IBM Plex Sans Arabic** — Amiri's calligraphic ligatures broke in large
headlines; Markazi is an editorial naskh with a matching Latin serif (brand.md asks for a serif
headline); Plex Arabic is highly legible at body size. Two families total.
- **Balance over olive photography, no drawn tatreez** — Naser rejected the cross-stitch drawings
as "tech stuff"; the balance is the firm's name and reads as law at a glance.
- **Article numbering** — frames the firm's content in the language of a statute; costs nothing.
- **Text-only article cards** — posts have no image field (code-fit check); no images are promised.
- **Copy limited to what the code backs** — no availability or response-time claims; booking copy
is drawn from the existing lang strings.
- **Public-only scope** — the dashboards share global tokens and `.btn` classes; the redesign is
scoped to the public layout so no dashboard screen changes.
- **Compact booking on the home page** — the client approved a one-screen form; it reuses the same
component and rules, so the backend does not change.
## Open Questions
None blocking. Rulings taken for the build (ledger, 2026-10-31):
1. **Photography** — the mockup's four Unsplash photos (olive tree, embroidery hoop, olives, hills)
ship as the production images; the Unsplash licence allows commercial use without attribution.
The client may replace them later with their own.
2. **Home day strip** — the next 14 calendar days with an available slot; `/booking` keeps the
month calendar.
3. **Pages the mockup does not show** (about, articles, booking page, success, legal) — composed
from the homepage's components with their existing content; no new sections.
4. **Auth pages** — type and controls only; composition unchanged.
---
# Part B — Dashboards, emails and the Nablus form (pre-redesign record)
Retrospective documentation of the design system as it is currently **built and live**. This
is not a proposal — it describes what shipped, so a dev/QA agent picking up a new task can match
+681
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
'progress_label' => 'تقدّم القراءة',
'brand_name' => 'ليبرا',
'brand_sub' => 'للحقوق',
'brand_label' => 'ليبرا للحقوق',
'nav_main' => 'القائمة الرئيسية',
'nav_home' => 'الرئيسية',
'nav_about' => 'من نحن',
'nav_services' => 'الخدمات',
'nav_articles' => 'المقالات',
'nav_booking' => 'الحجز',
'nav_login' => 'دخول العملاء',
'nav_book' => 'احجز استشارة',
'nav_menu' => 'القائمة',
'lang_switch' => 'English',
'footer_explore' => 'تصفّح',
'footer_clients' => 'العملاء',
'footer_contact' => 'تواصل',
'footer_terms' => 'شروط الاستخدام',
'footer_privacy' => 'سياسة الخصوصية',
'hero_eyebrow' => 'ليبرا للحقوق · فلسطين',
'hero_title_a' => 'العدالة ',
'hero_title_em' => 'ممارسةٌ يومية',
'hero_title_b' => '، لا مبدأٌ نظري.',
'trust_1_title' => 'أفراد وشركات',
'trust_1_text' => 'استشارات وتمثيل',
'trust_2_title' => 'المرأة والأسرة',
'trust_2_text' => 'دعم قانوني متخصص',
'trust_3_title' => 'عربي · English',
'trust_3_text' => 'خدمة بلغتين',
'alt_olive_tree' => 'شجرة زيتون معمّرة بين صخور كلسية',
'balance_label' => 'ميزان العدالة',
'frame_caption' => 'لكلّ حقٍّ وزنُه',
'frame_tag' => 'استشارات حضورية أو عن بُعد',
'pillars_label' => 'ركائزنا',
'pillar_justice' => 'العدالة',
'pillar_justice_text' => 'الإنصاف، والمساءلة، وسيادة القانون.',
'pillar_dignity' => 'الكرامة',
'pillar_dignity_text' => 'القيمة الأصيلة لكل إنسان.',
'pillar_advocacy' => 'الدفاع',
'pillar_advocacy_text' => 'انخراطٌ فاعل، وقولُ الحق في وجه السلطة.',
'art_founder' => 'المادة ٠١ — المؤسِّسة',
'founder_role' => 'المؤسِّسة · محامية',
'founder_quote' => '«العدالة ممارسة يومية، وليست مجرد مبدأ نظري.»',
'founder_cta' => 'اقرأ قصتنا',
'art_services' => 'المادة ٠٢ — الخدمات',
'services_lede' => 'ممارسة قانونية أصيلة، تقوم على البحث والمعرفة الميدانية.',
'art_vision' => 'المادة ٠٣ — الرؤية',
'vision_title' => 'منسوجةٌ من نسيج المجتمع',
'vision_text' => 'ليبرا نسيجٌ أصيل من المجتمع، تتقصّى مشكلاته وتوثّقها وتكشف جذورها، وتقدّم حلولاً عملية لرأب أوجاعه مهما صغرت، عبر العمل التشاركي والبحث الميداني الحقيقي.',
'alt_hoop' => 'تطريز بغرزة الصليب مشدود على طارة خشبية',
'card_institution_label' => 'لمؤسستنا',
'card_institution_title' => 'القانون والبحث والميدان',
'card_institution_text' => 'نلتقط نبض المجتمع ونحوّل تحدياته إلى معرفة وحلول قانونية مبتكرة.',
'alt_olives' => 'ثمار زيتون على غصن',
'card_society_label' => 'للمجتمع',
'card_society_title' => 'شريكٌ موثوق للقضاء',
'card_society_text' => 'نعيد وصل المجتمع بتاريخه وإرثه القانوني.',
'alt_hills' => 'تلال وطريق متعرّج عند الغروب',
'card_diaspora_label' => 'لفلسطينيي الشتات',
'card_diaspora_title' => 'عودةٌ إلى الأرض والعائلة',
'card_diaspora_text' => 'نساعد الفلسطينيين في الخارج على البحث في عائلاتهم وتاريخهم وأراضيهم.',
'art_values' => 'المادة ٠٤ — القيم',
'art_articles' => 'المادة ٠٥ — المقالات',
'art_booking' => 'المادة ٠٦ — الاستشارة',
'booking_title' => 'احجز استشارة',
'booking_lede' => 'اختر يوماً ووقتاً متاحَين، وأخبرنا باختصار عن قضيتك. نراجع كل طلب ونؤكّد الموعد بالبريد الإلكتروني — دون الحاجة إلى حساب.',
'booking_day' => 'اليوم',
'booking_time' => 'الوقت',
'booking_submit' => 'اطلب هذا الموعد',
'booking_none' => 'لا توجد مواعيد متاحة حالياً',
'captcha_refresh' => 'سؤال جديد',
'about_art_vision' => 'المادة ٠١ — الرؤية والرسالة',
'about_art_values' => 'المادة ٠٢ — القيم',
'about_art_goals' => 'المادة ٠٣ — الأهداف',
'about_art_services' => 'المادة ٠٤ — الخدمات',
'about_cta_title' => 'لكلّ حقٍّ وزنُه',
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
'progress_label' => 'Reading progress',
'brand_name' => 'Libra',
'brand_sub' => 'FOR RIGHTS',
'brand_label' => 'Libra for Rights',
'nav_main' => 'Main',
'nav_home' => 'Home',
'nav_about' => 'About',
'nav_services' => 'Services',
'nav_articles' => 'Articles',
'nav_booking' => 'Booking',
'nav_login' => 'Client login',
'nav_book' => 'Book a consultation',
'nav_menu' => 'Menu',
'lang_switch' => 'العربية',
'footer_explore' => 'Explore',
'footer_clients' => 'Clients',
'footer_contact' => 'Contact',
'footer_terms' => 'Terms of use',
'footer_privacy' => 'Privacy policy',
'hero_eyebrow' => 'Libra for Rights · Palestine',
'hero_title_a' => 'Justice is a ',
'hero_title_em' => 'daily practice',
'hero_title_b' => ', not a theory.',
'trust_1_title' => 'Individuals & companies',
'trust_1_text' => 'Consultations and representation',
'trust_2_title' => 'Women & family',
'trust_2_text' => 'Specialised legal support',
'trust_3_title' => 'Arabic · English',
'trust_3_text' => 'Bilingual service',
'alt_olive_tree' => 'An ancient olive tree among limestone rocks',
'balance_label' => 'The scales of justice',
'frame_caption' => 'Every right carries its weight',
'frame_tag' => 'Consultations in person or remotely',
'pillars_label' => 'Our pillars',
'pillar_justice' => 'Justice',
'pillar_justice_text' => 'Fairness, accountability and the rule of law.',
'pillar_dignity' => 'Dignity',
'pillar_dignity_text' => 'The inherent worth of every person.',
'pillar_advocacy' => 'Advocacy',
'pillar_advocacy_text' => 'Active engagement, and speaking truth to power.',
'art_founder' => 'Article 01 — The founder',
'founder_role' => 'Founder · Attorney at Law',
'founder_quote' => '“Justice is a daily practice, not merely a theoretical principle.”',
'founder_cta' => 'Read our story',
'art_services' => 'Article 02 — Services',
'services_lede' => 'Traditional legal practice, combined with research-driven insight.',
'art_vision' => 'Article 03 — Vision',
'vision_title' => 'Woven from the fabric of society',
'vision_text' => 'Libra investigates society\'s problems, documents them, uncovers their roots, and offers practical solutions to mend social pains — however small — through collaborative work and genuine field research.',
'alt_hoop' => 'Cross-stitch embroidery stretched on a wooden hoop',
'card_institution_label' => 'For our institution',
'card_institution_title' => 'Law, research and fieldwork',
'card_institution_text' => 'Capturing society\'s pulse and turning its challenges into knowledge and innovative legal solutions.',
'alt_olives' => 'Olives ripening on a branch',
'card_society_label' => 'For society',
'card_society_title' => 'A trusted partner to the judiciary',
'card_society_text' => 'Reconnecting society with its history and legal heritage.',
'alt_hills' => 'Hills and a winding road at sunset',
'card_diaspora_label' => 'For Palestinians in the diaspora',
'card_diaspora_title' => 'Back to the land and the family',
'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',
'captcha_refresh' => 'New question',
'about_art_vision' => 'Article 01 — Vision and mission',
'about_art_values' => 'Article 02 — Values',
'about_art_goals' => 'Article 03 — Goals',
'about_art_services' => 'Article 04 — Services',
'about_cta_title' => 'Every right carries its weight',
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+402
View File
@@ -0,0 +1,402 @@
/* Public-site redesign foundation (PRD 1.4, task 24-1).
Every selector is scoped under body.site so the dashboards are unaffected.
Reference: docs/ux-mockup.html */
/* ---------- Tokens ---------- */
body.site{
--forest:#2D3624; --forest-2:#232A1C; --gold:#A68966; --gold-ink:#7A6648; --gold-lt:#C4A882;
--cream:#F4F1EA; --olive:#4A5D23; --ink:#1E2219; --muted:#5B6152; --line:rgba(45,54,36,.14);
--display:"Markazi Text","Times New Roman",serif; --body:"IBM Plex Sans Arabic",system-ui,sans-serif;
--r:10px; --ease:cubic-bezier(.2,.7,.2,1);
--shadow-1:0 1px 2px rgba(30,34,25,.06),0 4px 14px rgba(30,34,25,.06);
--shadow-2:0 2px 4px rgba(30,34,25,.08),0 18px 40px -12px rgba(30,34,25,.28);
}
html:has(body.site){scroll-behavior:smooth}
/* app.css sets overflow-x:hidden on body, which turns it into a scroll container and breaks position:sticky */
body.site{overflow-x:clip;overflow-y:visible}
/* app.css sets the body font with html[lang] body (higher specificity than body.site) */
html[lang] body.site{font-family:var(--body)}
/* ---------- Ground and type ---------- */
body.site{
font-family:var(--body);color:var(--ink);font-size:17px;line-height:1.75;
background-color:var(--cream);
background-image:
repeating-linear-gradient(0deg, rgba(45,54,36,.025) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(45,54,36,.022) 0 1px, transparent 1px 4px);
}
body.site img{max-width:100%;display:block}
body.site a{color:inherit}
body.site .wrap{max-width:1200px;margin-inline:auto;padding-inline:28px}
body.site .skip{position:absolute;inset-inline-start:12px;top:-60px;background:var(--forest);color:var(--cream);padding:10px 16px;border-radius:8px;z-index:100}
body.site .skip:focus{top:12px}
body.site :focus-visible{outline:2px solid var(--gold);outline-offset:3px;border-radius:4px}
body.site h1,body.site h2,body.site h3{font-family:var(--display);font-weight:700;line-height:1.25;margin:0;color:var(--ink)}
body.site .eyebrow{display:inline-flex;align-items:center;gap:10px;font-size:.82rem;font-weight:600;letter-spacing:0;color:var(--gold-ink)}
html[lang=en] body.site .eyebrow{letter-spacing:.16em;text-transform:uppercase;font-size:.74rem}
body.site .eyebrow::before{content:"";width:22px;height:2px;background:currentColor;border-radius:2px}
body.site .art-no{font-family:var(--display);font-size:1.15rem;font-weight:600;color:var(--gold-ink)}
/* ---------- Buttons ---------- */
body.site .btn{position:relative;display:inline-flex;align-items:center;gap:10px;min-height:48px;padding:12px 26px;border-radius:999px;
font:600 1rem/1 var(--body);text-decoration:none;cursor:pointer;border:0;overflow:hidden;isolation:isolate;
transition:transform .25s var(--ease), box-shadow .25s var(--ease), background-color .25s}
body.site .btn-gold{background:var(--gold);color:var(--ink);box-shadow:0 1px 0 rgba(255,255,255,.35) inset, 0 8px 22px -8px rgba(166,137,102,.8)}
body.site .btn-gold::after{content:"";position:absolute;inset:0;z-index:-1;
background:linear-gradient(105deg,transparent 30%,rgba(255,255,255,.55) 50%,transparent 70%);transform:translateX(-120%);transition:transform .7s var(--ease)}
body.site .btn-gold:hover{transform:translateY(-2px);box-shadow:0 1px 0 rgba(255,255,255,.35) inset, 0 16px 34px -10px rgba(166,137,102,.95), 0 0 0 6px rgba(166,137,102,.14)}
body.site .btn-gold:hover::after{transform:translateX(120%)}
body.site .btn-ghost{background:transparent;color:var(--ink);box-shadow:inset 0 0 0 1.5px var(--forest)}
body.site .btn-ghost:hover{background:var(--forest);color:var(--cream);transform:translateY(-2px);box-shadow:inset 0 0 0 1.5px var(--forest), 0 12px 26px -12px rgba(45,54,36,.7)}
body.site .on-dark .btn-ghost{color:var(--cream);box-shadow:inset 0 0 0 1.5px rgba(244,241,234,.6)}
body.site .on-dark .btn-ghost:hover{background:var(--cream);color:var(--forest)}
body.site .btn .arr{transition:transform .25s var(--ease)}
body.site .btn:hover .arr{transform:translateX(-4px)}
html[dir=ltr] body.site .btn .arr{transform:scaleX(-1)}
html[dir=ltr] body.site .btn:hover .arr{transform:scaleX(-1) translateX(-4px)}
/* ---------- Scroll progress ---------- */
body.site .site-progress{position:fixed;z-index:60;top:0;inset-inline:0;height:2px;pointer-events:none;transform:scaleX(0);transform-origin:right;
background:linear-gradient(90deg,#8A7555,#E2CDA4,#A68966)}
html[dir=ltr] body.site .site-progress{transform-origin:left}
/* ---------- Sections ---------- */
body.site section{position:relative}
body.site .sec{padding:120px 0}
body.site .sec-head{display:flex;align-items:end;justify-content:space-between;gap:32px;margin-bottom:54px;flex-wrap:wrap}
body.site .sec-head h2,body.site .sec-head h1{font-size:clamp(2rem,3.4vw,3rem);margin-top:12px}
body.site .sec-head p{max-width:30em;color:var(--muted);margin:0}
/* ---------- Navigation (24-2) ---------- */
body.site .nav{position:sticky;top:0;z-index:50;background:rgba(244,241,234,.82);backdrop-filter:blur(14px) saturate(1.2);-webkit-backdrop-filter:blur(14px) saturate(1.2);
border-bottom:1px solid transparent;transition:border-color .3s, box-shadow .3s}
body.site .nav.scrolled{border-color:var(--line);box-shadow:0 8px 24px -18px rgba(30,34,25,.5)}
body.site .nav{box-sizing:border-box;height:72px}
body.site .nav .wrap{display:flex;align-items:center;gap:28px;height:71px}
body.site .brand{display:flex;align-items:center;gap:12px;text-decoration:none}
body.site .seal{width:46px;height:46px;border:2px solid #1A1A1A;box-shadow:3px 3px 5px rgba(26,26,26,.35);background:#fff center/cover no-repeat;flex:none}
body.site .brand b{font-family:var(--display);font-size:1.35rem;line-height:1;display:block}
body.site .brand small{display:block;font-size:.72rem;color:var(--muted)}
html[lang=en] body.site .brand small{letter-spacing:.12em}
body.site .links{display:flex;gap:4px;margin-inline-start:auto}
body.site .links a{position:relative;padding:10px 14px;text-decoration:none;font-weight:500;font-size:.95rem;border-radius:8px;transition:color .2s}
body.site .links a::after{content:"";position:absolute;inset-inline:14px;bottom:4px;height:2px;background:
repeating-linear-gradient(90deg,var(--gold) 0 4px,transparent 4px 7px);transform:scaleX(0);transform-origin:var(--o,right);transition:transform .35s var(--ease)}
html[dir=ltr] body.site .links a::after{--o:left}
body.site .links a:hover{color:var(--olive)}
body.site .links a:hover::after,body.site .links a[aria-current]::after{transform:scaleX(1)}
body.site .nav-tools{display:flex;align-items:center;gap:10px}
body.site .lang{min-width:48px;min-height:44px;border-radius:999px;border:1.5px solid var(--line);background:#fff;font:600 .85rem var(--body);cursor:pointer;transition:border-color .2s, transform .2s}
body.site .lang:hover{border-color:var(--gold);transform:translateY(-1px)}
body.site .login{font-size:.92rem;font-weight:500;text-decoration:none;padding:10px 12px;min-height:44px;display:inline-flex;align-items:center}
body.site .login:hover{color:var(--olive)}
body.site .nav .btn{min-height:44px;padding:10px 20px;font-size:.92rem}
body.site .menu-btn{display:none;width:44px;height:44px;border-radius:10px;border:1.5px solid var(--line);background:#fff;cursor:pointer}
body.site .nav .seal{background-color:#fff}
body.site .lang{display:inline-grid;place-items:center;text-decoration:none;color:var(--ink);padding:0 12px}
body.site .nav button.login{background:none;border:0;cursor:pointer;font-family:var(--body);color:inherit}
body.site .menu-sheet{position:absolute;inset-inline:0;top:100%;background:var(--cream);border-bottom:1px solid var(--line);box-shadow:var(--shadow-2);padding:6px 18px 18px}
body.site .menu-sheet a:not(.btn),body.site .menu-sheet button{display:flex;align-items:center;width:100%;min-height:48px;padding:0 4px;border:0;border-bottom:1px solid var(--line);background:none;font:500 1rem var(--body);color:var(--ink);text-decoration:none;text-align:start;cursor:pointer}
body.site .menu-sheet a[aria-current]{color:var(--olive)}
body.site .menu-sheet .btn{width:100%;justify-content:center;margin-top:14px}
@media (min-width:1101px){body.site .menu-sheet{display:none!important}}
/* ---------- Footer (24-2) ---------- */
body.site .site-footer{background:var(--forest-2);color:var(--gold-lt);margin-top:120px;padding:72px 0 28px;font-size:.92rem;position:relative}
body.site .foot-band{position:absolute;top:0;left:0;width:100%;height:1px;background:linear-gradient(90deg,transparent,rgba(196,168,130,.7),transparent)}
body.site .foot{display:grid;grid-template-columns:1.4fr 1fr 1fr 1fr;gap:40px}
body.site .foot h4{color:var(--cream);font-family:var(--display);font-size:1.15rem;margin:0 0 14px}
body.site .foot a{text-decoration:none;display:block;padding:10px 0;transition:color .2s, transform .2s}
body.site .foot a:hover{color:var(--cream);transform:translateX(-3px)}
html[dir=ltr] body.site .foot a:hover{transform:translateX(3px)}
body.site .foot .brand b{color:var(--cream)}
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,.9)}
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}
body.site .foot{grid-template-columns:1fr 1fr}
}
@media (max-width:760px){
body.site .nav-tools .btn,body.site .nav-tools .login{display:none}
body.site .foot{grid-template-columns:1fr}
}
body.site .site-balance{display:block;overflow:visible}
/* ---------- Home page (24-4) ---------- */
body.site .hero{position:relative;padding:56px 0 96px;overflow:hidden}
body.site .hero .wrap{display:grid;grid-template-columns:1fr 1.08fr;gap:56px;align-items:center}
body.site .hero h1{font-size:clamp(2.6rem,4.6vw,4.1rem);line-height:1.12;margin:18px 0 22px;letter-spacing:0;word-spacing:.08em}
html[lang=en] body.site .hero h1{letter-spacing:-.02em}
body.site .hero h1 em{font-style:normal;color:var(--olive);text-decoration:underline dashed var(--gold) .07em;text-underline-offset:.28em;text-decoration-skip-ink:none}
body.site .lede{font-size:1.15rem;color:#3A4033;max-width:34em;margin:0 0 30px}
body.site .cta-row{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
body.site .trust{display:flex;gap:26px;margin-top:40px;padding-top:24px;border-top:1px solid var(--line);flex-wrap:wrap}
body.site .trust div{font-size:.88rem;color:var(--muted);line-height:1.5}
body.site .trust strong{display:block;font-family:var(--display);font-size:1.5rem;color:var(--ink)}
body.site .frame{position:relative;aspect-ratio:4/4.6;border-radius:999px 999px 18px 18px;overflow:hidden;background:var(--forest);
box-shadow:0 2px 0 rgba(255,255,255,.6) inset, 0 40px 70px -30px rgba(30,34,25,.55), 0 0 0 10px rgba(244,241,234,1), 0 0 0 11px var(--line)}
body.site .frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;filter:saturate(.75) contrast(1.05);transform:scale(1.08);transition:transform .6s var(--ease)}
body.site .frame::before{content:"";position:absolute;inset:0;z-index:1;
background:linear-gradient(180deg,rgba(35,42,28,.35) 0%,rgba(35,42,28,.62) 45%,rgba(35,42,28,.9) 100%)}
body.site .frame .glow{position:absolute;z-index:1;width:420px;height:420px;border-radius:50%;pointer-events:none;
background:radial-gradient(circle,rgba(196,168,130,.28),transparent 65%);transform:translate(-50%,-50%);left:50%;top:45%;transition:opacity .4s}
body.site #scales{position:absolute;z-index:2;inset:10% 8% 12%;width:84%;height:78%;overflow:visible}
body.site .frame figcaption{position:absolute;z-index:3;inset-inline:0;bottom:18px;text-align:center;color:rgba(244,241,234,.78);font-family:var(--display);font-style:italic;font-size:1rem}
body.site .tag{position:absolute;z-index:4;inset-inline-end:-18px;top:16%;background:var(--cream);border-radius:14px;padding:14px 18px;box-shadow:var(--shadow-2);
display:flex;gap:12px;align-items:center;max-width:250px;font-size:.86rem;line-height:1.45}
body.site .tag .dot{width:10px;height:10px;border-radius:50%;background:var(--olive);box-shadow:0 0 0 5px rgba(74,93,35,.16);flex:none;animation:site-pulse 2.4s infinite}
@keyframes site-pulse{50%{box-shadow:0 0 0 9px rgba(74,93,35,.05)}}
body.site .hero-art{position:relative}
body.site .band{background:var(--forest);color:var(--cream);position:relative;
background-image:radial-gradient(1200px 300px at 50% -10%,rgba(196,168,130,.16),transparent 70%),
repeating-linear-gradient(0deg,rgba(255,255,255,.018) 0 1px,transparent 1px 3px)}
body.site .pillars{display:grid;grid-template-columns:repeat(3,1fr);gap:0}
body.site .pillar{padding:44px 34px;position:relative;transition:background .35s}
body.site .pillar+.pillar{border-inline-start:1px solid rgba(244,241,234,.12)}
body.site .pillar:hover{background:rgba(244,241,234,.04)}
body.site .pillar .k{font-family:var(--display);font-size:2.1rem;color:var(--cream);display:flex;align-items:center;gap:14px}
body.site .pillar .k svg{width:26px;height:26px;flex:none}
body.site .pillar p{margin:8px 0 0;color:var(--gold-lt);font-size:.98rem;max-width:22em}
body.site .founder{display:grid;grid-template-columns:.9fr 1.1fr;gap:72px;align-items:center}
body.site .portrait{position:relative;border-radius:18px;overflow:hidden;background:#E9E4D8;aspect-ratio:4/5;box-shadow:var(--shadow-2)}
body.site .portrait img{width:100%;height:100%;object-fit:cover;object-position:50% 20%;filter:grayscale(1) contrast(1.06) brightness(1.04);mix-blend-mode:multiply;transition:transform .8s var(--ease), filter .8s}
body.site .portrait:hover img{transform:scale(1.03);filter:grayscale(.65) contrast(1.06) brightness(1.04)}
body.site .portrait::after{content:"";position:absolute;inset:14px;border:1.5px dashed rgba(166,137,102,.7);border-radius:10px;pointer-events:none}
body.site .namecard{position:absolute;inset-inline-start:22px;bottom:22px;background:rgba(244,241,234,.92);backdrop-filter:blur(6px);padding:12px 18px;border-radius:12px;box-shadow:var(--shadow-1)}
body.site .namecard b{font-family:var(--display);font-size:1.3rem;display:block;line-height:1.2}
body.site .namecard span{font-size:.85rem;color:var(--muted)}
body.site blockquote{margin:22px 0 26px;font-family:var(--display);font-size:clamp(1.6rem,2.6vw,2.2rem);line-height:1.45;color:var(--forest);position:relative;padding-inline-start:28px;border-inline-start:3px solid var(--gold)}
body.site .founder p{color:#3A4033;margin:0 0 16px}
body.site .sign{display:flex;align-items:center;gap:16px;margin-top:26px}
body.site .docket{border-top:1px solid var(--line)}
body.site .row{display:grid;grid-template-columns:90px 1.1fr 1.4fr 56px;gap:24px;align-items:center;padding:30px 22px;border-bottom:1px solid var(--line);
position:relative;text-decoration:none;border-radius:14px;transition:background .35s var(--ease), color .35s, box-shadow .35s, transform .35s var(--ease)}
body.site .row .n{font-family:var(--display);font-size:2.4rem;color:var(--gold);line-height:1}
body.site .row h3{font-size:1.65rem}
body.site .chips{display:flex;flex-wrap:wrap;gap:8px}
body.site .chip{font-size:.85rem;padding:5px 12px;border-radius:999px;background:rgba(45,54,36,.06);color:#3A4033;transition:background .35s,color .35s}
body.site .go{width:48px;height:48px;border-radius:50%;display:grid;place-items:center;box-shadow:inset 0 0 0 1.5px var(--line);transition:all .35s var(--ease)}
html[dir=ltr] body.site .go svg{transform:scaleX(-1)}
body.site .row:hover{background:var(--forest);color:var(--cream);transform:translateY(-2px);box-shadow:0 24px 50px -24px rgba(30,34,25,.75);border-color:transparent}
body.site .row:hover h3{color:var(--cream)}
body.site .row:hover .chip{background:rgba(244,241,234,.1);color:var(--gold-lt)}
body.site .row:hover .go{background:var(--gold);box-shadow:0 0 0 6px rgba(166,137,102,.2);color:var(--ink)}
body.site .row::before{content:"";position:absolute;inset:8px;border-radius:10px;border:1.5px dashed rgba(196,168,130,0);transition:border-color .35s;pointer-events:none}
body.site .row:hover::before{border-color:rgba(196,168,130,.45)}
body.site .fabric{background:#ECE7DC}
body.site .fabric-grid{display:grid;grid-template-columns:1.15fr 1fr;gap:64px;align-items:center;margin-bottom:72px}
body.site .hoop{position:relative;border-radius:50%;aspect-ratio:1;overflow:hidden;box-shadow:0 0 0 14px #CDB792, 0 0 0 16px #8A7555, 0 40px 70px -30px rgba(30,34,25,.6);max-width:520px;margin-inline:auto}
body.site .hoop img{width:100%;height:100%;object-fit:cover;filter:hue-rotate(-140deg) saturate(.55) sepia(.25);transition:transform 1.2s var(--ease)}
body.site .hoop:hover img{transform:scale(1.06) rotate(-2deg)}
body.site .fabric h2{font-size:clamp(2rem,3.4vw,3.1rem);margin:14px 0 20px}
body.site .fabric .big{font-family:var(--display);font-size:1.35rem;line-height:1.7;color:var(--forest)}
body.site .three{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}
body.site .card{background:#fff;border-radius:16px;overflow:hidden;box-shadow:var(--shadow-1);border:1px solid rgba(45,54,36,.08);
transition:transform .35s var(--ease), box-shadow .35s var(--ease), border-color .35s;display:flex;flex-direction:column}
body.site .card:hover{transform:translateY(-6px);box-shadow:var(--shadow-2);border-color:rgba(166,137,102,.55)}
body.site .card .ph{height:190px;position:relative;overflow:hidden;background:var(--forest)}
body.site .card .ph img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease);filter:saturate(.8)}
body.site .card:hover .ph img{transform:scale(1.06)}
body.site .card .ph::after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 40%,rgba(35,42,28,.55))}
body.site .card .ph .lbl{position:absolute;z-index:1;inset-inline-start:18px;bottom:14px;color:var(--cream);font-family:var(--display);font-style:italic}
body.site .card .bd{padding:24px 26px 28px}
body.site .card h3{font-size:1.4rem;margin-bottom:8px}
body.site .card p{margin:0;color:var(--muted);font-size:.96rem}
body.site .motif-ph{display:grid;place-items:center}
body.site .motif-ph svg{width:150px;height:150px;margin-top:-10px}
body.site .sampler{display:grid;grid-template-columns:repeat(6,1fr);gap:16px}
body.site .val{background:#fff;border-radius:16px;padding:28px 14px 22px;text-align:center;border:1px solid rgba(45,54,36,.08);box-shadow:var(--shadow-1);
transition:transform .35s var(--ease), box-shadow .35s, border-color .35s}
body.site .medal{width:76px;height:76px;margin:0 auto 16px;border-radius:50%;display:grid;place-items:center;
background:radial-gradient(circle at 35% 30%,#FFFDF8,#EDE5D3 70%);
box-shadow:inset 0 0 0 1.5px rgba(166,137,102,.75), inset 0 -6px 12px rgba(138,117,85,.14), 0 0 0 5px #fff, 0 0 0 6px rgba(166,137,102,.35), 0 10px 20px -10px rgba(30,34,25,.45);
transition:box-shadow .35s var(--ease), transform .5s var(--ease)}
body.site .medal svg{width:32px;height:32px;fill:none;stroke:var(--gold-ink);stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;transition:stroke .35s}
body.site .val:hover .medal{transform:rotate(-8deg) scale(1.05);box-shadow:inset 0 0 0 1.5px var(--gold), inset 0 -6px 12px rgba(138,117,85,.14), 0 0 0 5px #fff, 0 0 0 6px var(--gold), 0 0 26px 2px rgba(166,137,102,.45)}
body.site .val:hover .medal svg{stroke:var(--olive)}
body.site .gem{width:12px;height:12px;transform:rotate(45deg);flex:none;background:linear-gradient(135deg,#F1E2C2,#A68966 60%,#6E5A3F);box-shadow:0 0 0 3px rgba(196,168,130,.18), 0 0 14px rgba(196,168,130,.5)}
body.site .val b{font-family:var(--display);font-size:1.2rem;display:block}
body.site .val:hover{transform:translateY(-6px) rotate(-.6deg);box-shadow:var(--shadow-2);border-color:rgba(166,137,102,.55)}
body.site .posts{display:grid;grid-template-columns:repeat(3,1fr);gap:22px}
body.site .post{position:relative;border-radius:16px;overflow:hidden;background:#fff;border:1px solid rgba(45,54,36,.08);box-shadow:var(--shadow-1);text-decoration:none;display:flex;flex-direction:column;
transition:transform .35s var(--ease), box-shadow .35s, border-color .35s}
body.site .post:hover{transform:translateY(-6px);box-shadow:var(--shadow-2);border-color:rgba(166,137,102,.55)}
body.site .post .ph{aspect-ratio:16/10;overflow:hidden;background:var(--forest)}
body.site .post .ph img{width:100%;height:100%;object-fit:cover;transition:transform .8s var(--ease)}
body.site .post:hover .ph img{transform:scale(1.05)}
body.site .post .bd{padding:30px 28px 28px;display:flex;flex-direction:column;gap:12px;flex:1;border-top:3px solid var(--gold);min-height:270px;position:relative}
body.site .post .bd::before{content:"”";position:absolute;inset-inline-end:22px;top:4px;font:700 5rem/1 var(--display);color:rgba(166,137,102,.22)}
body.site .post:hover .bd{border-top-color:var(--olive)}
body.site .post .meta{font-size:.8rem;color:var(--gold-ink);font-weight:600}
body.site .post h3{font-size:1.35rem}
body.site .post p{margin:0;color:var(--muted);font-size:.95rem}
body.site .post .more{margin-top:auto;font-weight:600;font-size:.9rem;color:var(--olive)}
body.site .post.text .ph{display:none}
body.site .post.text{background:var(--forest);color:var(--cream)}
body.site .post.text h3{color:var(--cream)}
body.site .post.text p{color:var(--gold-lt)}
body.site .post.text .meta{color:var(--gold-lt)}
body.site .post.text .more{color:var(--gold-lt)}
@media (max-width:1100px){
body.site .hero .wrap,body.site .founder,body.site .fabric-grid{grid-template-columns:1fr}
body.site .hero-art{max-width:560px;margin-inline:auto;width:100%}
body.site .sampler{grid-template-columns:repeat(3,1fr)}
body.site .posts{grid-template-columns:1fr 1fr}
body.site .posts .post:last-child{grid-column:1/-1}
}
@media (max-width:760px){
body.site .hero{padding:36px 0 64px}
body.site .tag{inset-inline-end:8px;top:auto;bottom:-20px}
body.site .pillars,body.site .three,body.site .posts{grid-template-columns:1fr}
body.site .pillar+.pillar{border-inline-start:0;border-top:1px solid rgba(244,241,234,.12)}
body.site .pillar{padding:30px 6px}
body.site .row{grid-template-columns:52px 1fr;padding:22px 12px;gap:10px 16px}
body.site .row .chips{grid-column:1/-1}
body.site .row .go{display:none}
body.site .row .n{font-size:1.8rem}
body.site .sampler{grid-template-columns:repeat(2,1fr)}
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}
}
/* ---------- Booking page and success (24-6) ---------- */
body.site .book-r .picked{display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;padding:14px 18px;margin-bottom:18px;border-radius:14px;background:#fff;border:1px solid var(--line)}
body.site .book-r .picked b{display:block;font-family:var(--display);font-size:1.15rem}
body.site .book-r .picked span{display:block;font-size:.92rem;color:var(--muted)}
body.site .book-r .picked .btn{width:auto;margin:0;min-height:44px;padding:8px 18px}
body.site .book-r .cap-row{display:flex;gap:10px;align-items:center}
body.site .book-r .cap-row .refresh{width:auto;margin:0;min-height:48px;padding:0 16px}
body.site .book-r .site-cal-nav{min-width:44px;min-height:44px;border-radius:999px;border:1.5px solid var(--line);background:#fff;color:var(--ink)}
body.site .book-r .site-cal-day{min-width:44px;min-height:44px;border-radius:10px;position:relative;font-weight:500}
body.site .book-r .site-cal-day.is-available{background:#fff;color:var(--ink);box-shadow:inset 0 0 0 1px var(--line)}
body.site .book-r .site-cal-day.is-available::after{content:"";position:absolute;inset-inline:0;bottom:4px;margin-inline:auto;width:6px;height:6px;border-radius:50%;background:var(--gold)}
body.site .book-r .site-cal-day.is-available:hover{box-shadow:inset 0 0 0 1.5px var(--gold)}
body.site .book-r .site-cal-day.is-selected{background:var(--forest);color:var(--cream);box-shadow:none}
body.site .book-r .site-cal-day.is-unavailable{background:transparent;color:var(--muted);opacity:.5;cursor:default}
body.site .success-card{max-width:560px;margin-inline:auto;background:var(--cream);border:1px solid var(--line);border-radius:20px;box-shadow:var(--shadow-2);padding:48px 40px;text-align:center}
body.site .success-card h1{font-size:clamp(2rem,3.4vw,2.6rem);margin-bottom:12px}
body.site .success-card p{color:var(--muted);margin:0 0 26px}
/* ---------- About page (24-7) ---------- */
body.site .row-static{grid-template-columns:90px 1fr}
body.site .row-static:hover{background:none;color:inherit;transform:none;box-shadow:none}
body.site .row-static::before{display:none}
@media (max-width:760px){body.site .row-static{grid-template-columns:52px 1fr}}
/* ---------- Articles and legal pages (24-8) ---------- */
body.site .search{min-height:48px;border-radius:999px;padding:12px 20px;border:1.5px solid rgba(45,54,36,.18);background:#fff;width:min(360px,100%);font:400 1rem var(--body);color:var(--ink);transition:border-color .2s,box-shadow .2s}
body.site .search:focus{outline:none;border-color:var(--gold);box-shadow:0 0 0 4px rgba(166,137,102,.2)}
body.site .search-info{color:var(--muted);margin:0 0 24px;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
body.site .search-info .btn{min-height:44px;padding:8px 18px}
body.site .posts-index .post:last-child{grid-column:auto}
body.site .pagination-wrap{margin-top:40px}
body.site .pagination-wrap nav>div:first-child{display:none}
body.site .pagination-wrap nav a,body.site .pagination-wrap nav span[aria-disabled] span,body.site .pagination-wrap nav span[aria-current] span{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:44px;padding:0 14px;border-radius:999px;border:1.5px solid var(--line);background:#fff;color:var(--ink);text-decoration:none;font-weight:500}
body.site .pagination-wrap nav a:hover{border-color:var(--gold)}
body.site .pagination-wrap nav span[aria-current] span{background:var(--forest);border-color:var(--forest);color:var(--cream)}
body.site .pagination-wrap nav span[aria-disabled] span{opacity:.5}
body.site .site-prose :is(h2,h3,h4){font-family:var(--display)}
body.site .site-prose a{color:var(--olive)}
body.site .site-prose{font-size:1.06rem;line-height:1.85}
body.site .post-meta,body.site article>.meta{display:block;font-size:.85rem;font-weight:600;color:var(--gold-ink)}
body.site .legal-card{background:#fff;border-radius:16px;box-shadow:var(--shadow-1);padding:40px}
body.site .legal-updated{font-size:.85rem;color:var(--muted);margin:16px 0 0}
@media (max-width:760px){body.site .legal-card{padding:24px}}
/* ---------- Auth pages: type and controls only (24-9) ---------- */
body.site-auth{font-family:"IBM Plex Sans Arabic",system-ui,sans-serif;color:#1E2219}
html[lang] body.site-auth{font-family:"IBM Plex Sans Arabic",system-ui,sans-serif}
body.site-auth h1,body.site-auth h2,body.site-auth [data-flux-heading]{font-family:"Markazi Text","Times New Roman",serif;font-weight:700}
body.site-auth input[type=email],body.site-auth input[type=password],body.site-auth input[type=text]{min-height:48px;border-radius:12px;border:1.5px solid rgba(45,54,36,.18);background:#fff}
body.site-auth input[type=email]:focus,body.site-auth input[type=password]:focus,body.site-auth input[type=text]:focus{outline:none;border-color:#A68966;box-shadow:0 0 0 4px rgba(166,137,102,.2)}
/* Flux always renders a hidden error element, so app.css paints every field red; only a visible error should */
body.site-auth [data-flux-field]:not(:has([data-flux-error]:not(.hidden))) input[data-flux-control]{border-color:rgba(45,54,36,.18)}
body.site-auth [data-flux-field]:not(:has([data-flux-error]:not(.hidden))) input[data-flux-control]:focus{border-color:#A68966;box-shadow:0 0 0 4px rgba(166,137,102,.2)}
body.site-auth [data-flux-button][type=submit],body.site-auth button[type=submit]{border-radius:999px;min-height:48px;background:#A68966;color:#1E2219;font-weight:600;box-shadow:0 1px 0 rgba(255,255,255,.35) inset,0 8px 22px -8px rgba(166,137,102,.8);transition:transform .25s cubic-bezier(.2,.7,.2,1),box-shadow .25s}
body.site-auth [data-flux-button][type=submit]:hover,body.site-auth button[type=submit]:hover{transform:translateY(-2px)}
body.site-auth main a:not([data-flux-button]):not([data-test^=language-switch]){color:#4A5D23}
body.site-auth main a:not([data-flux-button]):not([data-test^=language-switch]):hover{text-decoration:underline}
body.site-auth :is(a,button,input,select,textarea):focus-visible{outline:2px solid #A68966;outline-offset:3px}
@media (prefers-reduced-motion:reduce){body.site-auth *{transition:none!important}}
/* ---------- Reveal (hidden only when JavaScript runs) ---------- */
body.site.js [data-reveal]{opacity:0;translate:0 28px;transition:opacity .9s var(--ease),translate .9s var(--ease)}
body.site.js [data-reveal].in{opacity:1;translate:none}
body.site.js [data-reveal][data-d="1"]{transition-delay:.08s}
body.site.js [data-reveal][data-d="2"]{transition-delay:.16s}
body.site.js [data-reveal][data-d="3"]{transition-delay:.24s}
body.site.js [data-reveal][data-d="4"]{transition-delay:.32s}
body.site.js [data-reveal][data-d="5"]{transition-delay:.4s}
/* ---------- Responsive base ---------- */
@media (max-width:760px){
body.site{font-size:16px}
body.site .wrap{padding-inline:18px}
body.site .sec{padding:80px 0}
}
/* ---------- Reduced motion ---------- */
@media (prefers-reduced-motion:reduce){
body.site *, body.site *::before, body.site *::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}
body.site [data-reveal]{opacity:1;translate:none}
}
+14
View File
@@ -0,0 +1,14 @@
import * as reveal from './site/reveal.js';
import * as progress from './site/progress.js';
import * as balance from './site/balance.js';
const modules = [reveal, progress, balance];
document.addEventListener('livewire:navigated', () => {
document.body.classList.add('js');
modules.forEach((m) => m.init());
});
document.addEventListener('livewire:navigating', () => {
modules.forEach((m) => m.destroy());
});
+153
View File
@@ -0,0 +1,153 @@
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
let uid = 0;
let cleanups = [];
let frames = new Set();
function build(svg) {
const i = ++uid;
svg.innerHTML = `<defs>
<linearGradient id="gd${i}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#F6EAD0"/><stop offset=".42" stop-color="#C9AD84"/><stop offset=".72" stop-color="#A68966"/><stop offset="1" stop-color="#6A5640"/></linearGradient>
<linearGradient id="gc${i}" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6A5640"/><stop offset=".32" stop-color="#EBD9B4"/><stop offset=".55" stop-color="#B89A72"/><stop offset="1" stop-color="#5E4C37"/></linearGradient>
<linearGradient id="gb${i}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#F6EAD0"/><stop offset=".35" stop-color="#C4A882"/><stop offset="1" stop-color="#5E4C37"/></linearGradient>
<radialGradient id="gh${i}" cx=".35" cy=".3" r=".8"><stop offset="0" stop-color="#FFF6E2"/><stop offset=".5" stop-color="#C4A882"/><stop offset="1" stop-color="#6A5640"/></radialGradient>
<filter id="sh${i}" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="8" stdDeviation="7" flood-color="#0d1009" flood-opacity=".45"/></filter>
<filter id="bl${i}"><feGaussianBlur stdDeviation="6"/></filter>
</defs>
<ellipse cx="200" cy="358" rx="120" ry="9" fill="#0d1009" opacity=".55" filter="url(#bl${i})"/>
<g filter="url(#sh${i})">
<path d="M104 356 H296 L282 336 H118 Z" fill="url(#gc${i})"/><path d="M104 356 H296" stroke="#F6EAD0" stroke-opacity=".5"/>
<path d="M138 336 H262 L252 320 H148 Z" fill="url(#gc${i})"/>
<rect x="182" y="300" width="36" height="20" rx="4" fill="url(#gc${i})"/>
<rect x="191" y="118" width="18" height="184" fill="url(#gc${i})"/>
<path d="M196 124 V298" stroke="#FFF6E2" stroke-opacity=".55" stroke-width="1.4"/>
<rect x="184" y="128" width="32" height="9" rx="3" fill="url(#gc${i})"/>
<path d="M200 92 C186 80 184 64 194 52 C202 64 204 78 200 92Z M200 92 C214 80 216 64 206 52 C198 64 196 78 200 92Z" fill="url(#gd${i})"/>
<path d="M200 92 V60" stroke="#6A5640" stroke-width="1.2"/>
<g class="beam"><path d="M58 104 Q200 92 342 104 L342 114 Q200 104 58 114 Z" fill="url(#gb${i})"/>
<circle cx="58" cy="109" r="7" fill="url(#gh${i})"/><circle cx="342" cy="109" r="7" fill="url(#gh${i})"/></g>
<circle cx="200" cy="110" r="16" fill="url(#gh${i})"/><circle cx="200" cy="110" r="6" fill="#5E4C37"/><circle cx="198" cy="108" r="2" fill="#FFF6E2" opacity=".8"/>
${[0,1].map(k=>`<g class="pan pan${k}">
<path d="M0 0 L-54 118 M0 0 L54 118 M0 0 L0 120" stroke="#C4A882" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="0" cy="0" r="4.5" fill="url(#gh${i})"/>
<path d="M-66 118 Q0 182 66 118 Z" fill="url(#gd${i})"/>
<path d="M-56 132 Q0 166 40 140" stroke="#FFF6E2" stroke-opacity=".45" stroke-width="2" fill="none" stroke-linecap="round"/>
<ellipse cx="0" cy="118" rx="66" ry="7" fill="#EBD9B4" stroke="#8A7555" stroke-width="1"/></g>`).join('')}
</g>`;
const beam = svg.querySelector('.beam');
const pans = svg.querySelectorAll('.pan');
return function tilt(deg) {
const r = deg * Math.PI / 180;
const c = Math.cos(r) * 142;
const s = Math.sin(r) * 142;
beam.setAttribute('transform', `rotate(${deg} 200 110)`);
pans[0].setAttribute('transform', `translate(${200 - c} ${110 - s})`);
pans[1].setAttribute('transform', `translate(${200 + c} ${110 + s})`);
};
}
/* a damped spring, so the beam swings and settles like real brass */
function spring(tilt, start) {
const st = { a: start, v: 0, target: 0 };
let raf = 0;
const step = () => {
frames.delete(raf);
st.v += (st.target - st.a) * 0.028;
st.v *= 0.935;
st.a += st.v;
tilt(st.a);
if (Math.abs(st.v) > 0.002 || Math.abs(st.target - st.a) > 0.01) {
raf = requestAnimationFrame(step);
frames.add(raf);
} else {
raf = 0;
}
};
return {
set(target) {
st.target = target;
if (!raf) {
raf = requestAnimationFrame(step);
frames.add(raf);
}
},
};
}
function on(target, type, handler, options) {
target.addEventListener(type, handler, options);
cleanups.push(() => target.removeEventListener(type, handler, options));
}
function hero(svg, tilt) {
tilt(14);
const sp = spring(tilt, 14);
const timer = setTimeout(() => sp.set(0), 450);
cleanups.push(() => clearTimeout(timer));
const frame = svg.closest('.frame');
if (!frame || !matchMedia('(pointer:fine)').matches) return;
const glow = frame.querySelector('.glow');
const img = frame.querySelector('img');
on(frame, 'pointermove', (e) => {
const fr = frame.getBoundingClientRect();
const x = (e.clientX - fr.left) / fr.width;
sp.set(clamp((x - 0.5) * 2.4, -1, 1) * 9);
if (glow) {
glow.style.left = (e.clientX - fr.left) + 'px';
glow.style.top = (e.clientY - fr.top) + 'px';
}
if (img) {
img.style.transform = `scale(1.08) translate(${(e.clientX - fr.left - fr.width / 2) / -40}px, ${(e.clientY - fr.top - fr.height / 2) / -40}px)`;
}
});
on(frame, 'pointerleave', () => {
sp.set(0);
if (img) img.style.transform = '';
});
}
function finale(tilt) {
tilt(-12);
const sp = spring(tilt, -12);
const onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
const p = max > 0 ? window.scrollY / max : 1;
const q = clamp((p - 0.7) / 0.25, 0, 1);
sp.set(-12 * (1 - q));
};
on(window, 'scroll', onScroll, { passive: true });
onScroll();
}
export function init() {
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
document.querySelectorAll('svg[data-balance]').forEach((svg) => {
const tilt = build(svg);
const mode = svg.dataset.balance;
if (reduced || mode === 'static') {
tilt(0);
} else if (mode === 'hero') {
hero(svg, tilt);
} else if (mode === 'finale') {
finale(tilt);
}
});
}
export function destroy() {
cleanups.forEach((fn) => fn());
cleanups = [];
frames.forEach((id) => cancelAnimationFrame(id));
frames.clear();
}
+22
View File
@@ -0,0 +1,22 @@
let onScroll = null;
export function init() {
const bar = document.querySelector('.site-progress');
const nav = document.getElementById('site-nav');
onScroll = () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
const p = max > 0 ? window.scrollY / max : 1;
if (bar) bar.style.transform = `scaleX(${p})`;
nav?.classList.toggle('scrolled', window.scrollY > 8);
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
export function destroy() {
if (onScroll) window.removeEventListener('scroll', onScroll);
onScroll = null;
}
+26
View File
@@ -0,0 +1,26 @@
let observer = null;
export function init() {
const items = document.querySelectorAll('[data-reveal]');
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
items.forEach((el) => el.classList.add('in'));
return;
}
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('in');
observer.unobserve(entry.target);
}
});
}, { rootMargin: '0px 0px -8% 0px' });
items.forEach((el) => observer.observe(el));
}
export function destroy() {
observer?.disconnect();
observer = null;
}
+33 -58
View File
@@ -1,68 +1,43 @@
<footer class="bg-primary mt-auto" data-test="main-footer">
<div class="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
<!-- Logo & Description -->
<div class="text-center sm:text-start">
<x-logo size="lg" class="logo-badge" />
<p class="mt-3 text-cta text-sm">
{{ __('footer.description') }}
</p>
<footer class="site-footer" data-test="main-footer">
<span class="foot-band" aria-hidden="true"></span>
<div class="wrap">
<div class="foot">
<div>
<a href="{{ route('home') }}" class="brand" aria-label="{{ __('site.brand_label') }}" wire:navigate>
<span class="seal" style="background-image:url('{{ asset('images/logo.png') }}')"></span>
<span>
<b>{{ __('site.brand_name') }}</b>
<small>{{ __('site.brand_sub') }}</small>
</span>
</a>
<p>{{ __('home.tagline') }}</p>
</div>
<!-- Contact Information -->
<div class="text-center sm:text-start">
<h3 class="text-off-white font-semibold mb-3">{{ __('footer.contact') }}</h3>
<address class="text-cta text-sm not-italic space-y-2">
<p class="flex items-center justify-center sm:justify-start gap-2">
<flux:icon name="phone" class="w-4 h-4 text-cta flex-shrink-0" />
<a href="tel:+970599353502" class="hover:text-cta-light transition-colors ltr-inline" data-test="footer-phone">
+970 599 353 502
</a>
</p>
<p class="flex items-start justify-center sm:justify-start gap-2">
<flux:icon name="map-pin" class="w-4 h-4 text-cta flex-shrink-0 mt-0.5" />
<span data-test="footer-address">{{ __('footer.address') }}</span>
</p>
<p class="flex items-center justify-center sm:justify-start gap-2">
<flux:icon name="envelope" class="w-4 h-4 text-cta flex-shrink-0" />
<a href="mailto:{{ __('footer.email') }}" class="hover:text-cta-light transition-colors ltr-inline" data-test="footer-email">
{{ __('footer.email') }}
</a>
</p>
</address>
<div>
<h4>{{ __('site.footer_explore') }}</h4>
<a href="{{ route('about') }}" wire:navigate>{{ __('site.nav_about') }}</a>
<a href="{{ route('home') }}#services" wire:navigate>{{ __('site.nav_services') }}</a>
<a href="{{ route('posts.index') }}" wire:navigate>{{ __('site.nav_articles') }}</a>
<a href="{{ route('booking') }}" wire:navigate>{{ __('site.nav_booking') }}</a>
</div>
<!-- Legal Links -->
<div class="text-center sm:text-start sm:col-span-2 lg:col-span-1">
<h3 class="text-off-white font-semibold mb-3">{{ __('footer.legal') }}</h3>
<ul class="space-y-2 flex flex-row sm:flex-col gap-4 sm:gap-0 justify-center sm:justify-start">
<li>
<a
href="{{ route('terms') }}"
class="text-cta hover:text-cta-light transition-colors text-sm min-h-[44px] inline-flex items-center"
data-test="footer-terms"
>
{{ __('footer.terms') }}
</a>
</li>
<li>
<a
href="{{ route('privacy') }}"
class="text-cta hover:text-cta-light transition-colors text-sm min-h-[44px] inline-flex items-center"
data-test="footer-privacy"
>
{{ __('footer.privacy') }}
</a>
</li>
</ul>
<div>
<h4>{{ __('site.footer_clients') }}</h4>
<a href="{{ route('login') }}" wire:navigate>{{ __('site.nav_login') }}</a>
<a href="{{ route('terms') }}" wire:navigate data-test="footer-terms">{{ __('site.footer_terms') }}</a>
<a href="{{ route('privacy') }}" wire:navigate data-test="footer-privacy">{{ __('site.footer_privacy') }}</a>
</div>
<div>
<h4>{{ __('site.footer_contact') }}</h4>
<a href="tel:+970599353502" dir="ltr" data-test="footer-phone">{{ __('footer.phone') }}</a>
<a href="mailto:{{ __('footer.email') }}" data-test="footer-email">{{ __('footer.email') }}</a>
<span data-test="footer-address">{{ __('footer.address') }}</span>
</div>
</div>
<!-- Copyright -->
<div class="mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-cta/20 text-center">
<p class="text-cta text-xs sm:text-sm" data-test="footer-copyright">
&copy; {{ date('Y') }} {{ __('footer.copyright') }}
</p>
<div class="legal">
<span data-test="footer-copyright">&copy; {{ date('Y') }} {{ __('footer.copyright') }}</span>
</div>
</div>
</footer>
@@ -2,8 +2,10 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
@include('partials.head')
@include('partials.site-fonts')
@vite(['resources/css/public.css'])
</head>
<body class="min-h-screen bg-neutral-100 antialiased" style="font-family: var(--font-{{ app()->getLocale() === 'ar' ? 'arabic' : 'english' }})">
<body class="site-auth min-h-screen bg-neutral-100 antialiased">
<!-- Skip to content link for keyboard accessibility -->
<a href="#main-content" class="skip-link" data-test="skip-to-content">
{{ __('accessibility.skip_to_content') }}
@@ -2,8 +2,10 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
@include('partials.head')
@include('partials.site-fonts')
@vite(['resources/css/public.css'])
</head>
<body class="min-h-screen bg-white antialiased" style="font-family: var(--font-{{ app()->getLocale() === 'ar' ? 'arabic' : 'english' }})">
<body class="site-auth min-h-screen bg-white antialiased">
<!-- Skip to content link for keyboard accessibility -->
<a href="#main-content" class="skip-link" data-test="skip-to-content">
{{ __('accessibility.skip_to_content') }}
@@ -2,8 +2,10 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
@include('partials.head')
@include('partials.site-fonts')
@vite(['resources/css/public.css'])
</head>
<body class="min-h-screen bg-background antialiased" style="font-family: var(--font-{{ app()->getLocale() === 'ar' ? 'arabic' : 'english' }})">
<body class="site-auth min-h-screen bg-background antialiased">
<!-- Skip to content link for keyboard accessibility -->
<a href="#main-content" class="skip-link" data-test="skip-to-content">
{{ __('accessibility.skip_to_content') }}
@@ -2,22 +2,20 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
<head>
@include('partials.head')
@include('partials.site-fonts')
@vite(['resources/css/public.css', 'resources/js/public.js'])
</head>
<body class="min-h-screen flex flex-col bg-background" style="font-family: var(--font-{{ app()->getLocale() === 'ar' ? 'arabic' : 'english' }})">
<body class="site min-h-screen flex flex-col">
<!-- Skip to content link for keyboard accessibility -->
<a href="#main-content" class="skip-link" data-test="skip-to-content">
{{ __('accessibility.skip_to_content') }}
</a>
<div class="site-progress" aria-hidden="true"></div>
<x-navigation />
<!-- Spacer for fixed navigation -->
<div class="h-16"></div>
<main id="main-content" role="main" class="flex-1" tabindex="-1">
<div class="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
{{ $slot }}
</div>
{{ $slot }}
</main>
<x-footer />
+84 -217
View File
@@ -1,141 +1,78 @@
<nav
@php
$isAr = app()->getLocale() === 'ar';
$links = [
['key' => 'home', 'href' => route('home'), 'label' => __('site.nav_home'), 'current' => request()->routeIs('home')],
['key' => 'about', 'href' => route('about'), 'label' => __('site.nav_about'), 'current' => request()->routeIs('about')],
['key' => 'services', 'href' => route('home').'#services', 'label' => __('site.nav_services'), 'current' => false],
['key' => 'posts', 'href' => route('posts.index'), 'label' => __('site.nav_articles'), 'current' => request()->routeIs('posts*')],
['key' => 'booking', 'href' => route('booking'), 'label' => __('site.nav_booking'), 'current' => request()->routeIs('booking*')],
];
$langHref = url('/language/'.($isAr ? 'en' : 'ar'));
$langText = $isAr ? 'EN' : 'ع';
@endphp
<header
x-data="{ mobileMenuOpen: false }"
class="fixed top-0 inset-x-0 z-50 bg-primary"
class="nav"
id="site-nav"
data-test="main-navigation"
>
<div class="max-w-[1200px] mx-auto px-4">
<div class="flex items-center justify-between h-16">
<!-- Logo - Left on desktop -->
<div class="shrink-0 hidden md:block">
<a href="{{ route('home') }}" class="flex items-center" wire:navigate>
<x-logo size="md" class="logo-badge" />
</a>
</div>
<div class="wrap">
<a href="{{ route('home') }}" class="brand" aria-label="{{ __('site.brand_label') }}" wire:navigate>
<span class="seal" style="background-image:url('{{ asset('images/logo.png') }}')"></span>
<span>
<b>{{ __('site.brand_name') }}</b>
<small>{{ __('site.brand_sub') }}</small>
</span>
</a>
<!-- Mobile: Hamburger + Centered Logo + Language -->
<div class="flex items-center justify-between w-full md:hidden">
<!-- Hamburger Button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="p-2 text-off-white hover:text-cta focus:outline-none min-h-[44px] min-w-[44px] flex items-center justify-center"
:aria-expanded="mobileMenuOpen"
aria-label="{{ __('Toggle navigation menu') }}"
data-test="mobile-menu-button"
>
<svg x-show="!mobileMenuOpen" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg x-show="mobileMenuOpen" x-cloak class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Centered Logo on Mobile -->
<a href="{{ route('home') }}" class="flex items-center" wire:navigate>
<x-logo size="sm" class="logo-badge" />
</a>
<!-- Language Toggle on Mobile -->
<div class="min-w-[44px]">
<x-language-toggle />
</div>
</div>
<!-- Desktop Navigation Links -->
<div class="hidden md:flex md:items-center md:gap-6">
<nav class="links" aria-label="{{ __('site.nav_main') }}">
@foreach ($links as $link)
<a
href="{{ route('home') }}"
@class([
'text-off-white hover:text-cta transition-colors py-2',
'border-b-2 border-cta' => request()->routeIs('home'),
])
href="{{ $link['href'] }}"
@if ($link['current']) aria-current="page" @endif
wire:navigate
data-test="nav-home"
>
{{ __('navigation.home') }}
</a>
<a
href="{{ route('about') }}"
@class([
'text-off-white hover:text-cta transition-colors py-2',
'border-b-2 border-cta' => request()->routeIs('about'),
])
wire:navigate
data-test="nav-about"
>
{{ __('navigation.about') }}
</a>
<a
href="{{ route('booking') }}"
@class([
'text-off-white hover:text-cta transition-colors py-2',
'border-b-2 border-cta' => request()->routeIs('booking*'),
])
wire:navigate
data-test="nav-booking"
>
{{ __('navigation.booking') }}
</a>
<a
href="{{ route('posts.index') }}"
@class([
'text-off-white hover:text-cta transition-colors py-2',
'border-b-2 border-cta' => request()->routeIs('posts*'),
])
wire:navigate
data-test="nav-posts"
>
{{ __('navigation.posts') }}
</a>
data-test="nav-{{ $link['key'] }}"
>{{ $link['label'] }}</a>
@endforeach
</nav>
@auth
@php
$dashboardRoute = auth()->user()->isAdmin() ? route('admin.dashboard') : route('client.dashboard');
@endphp
<a
href="{{ $dashboardRoute }}"
@class([
'text-off-white hover:text-cta transition-colors py-2',
'border-b-2 border-cta' => request()->routeIs('*.dashboard'),
])
wire:navigate
data-test="nav-dashboard"
>
{{ __('navigation.dashboard') }}
</a>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button
type="submit"
class="text-off-white hover:text-cta transition-colors py-2"
data-test="nav-logout"
>
{{ __('navigation.logout') }}
</button>
</form>
@else
<a
href="{{ route('login') }}"
@class([
'text-off-white hover:text-cta transition-colors py-2',
'border-b-2 border-cta' => request()->routeIs('login'),
])
wire:navigate
data-test="nav-login"
>
{{ __('navigation.login') }}
</a>
@endauth
</div>
<div class="nav-tools">
<a class="lang" href="{{ $langHref }}" aria-label="{{ __('site.lang_switch') }}" data-test="nav-language">{{ $langText }}</a>
<!-- Desktop Language Toggle -->
<div class="hidden md:block">
<x-language-toggle />
</div>
@auth
@php
$dashboardRoute = auth()->user()->isAdmin() ? route('admin.dashboard') : route('client.dashboard');
@endphp
<a class="login" href="{{ $dashboardRoute }}" wire:navigate data-test="nav-dashboard">{{ __('navigation.dashboard') }}</a>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="login" data-test="nav-logout">{{ __('navigation.logout') }}</button>
</form>
@else
<a class="login" href="{{ route('login') }}" wire:navigate data-test="nav-login">{{ __('site.nav_login') }}</a>
@endauth
<a class="btn btn-gold" href="{{ route('home') }}#book">{{ __('site.nav_book') }}</a>
<button
type="button"
class="menu-btn"
@click="mobileMenuOpen = !mobileMenuOpen"
:aria-expanded="mobileMenuOpen"
aria-label="{{ __('site.nav_menu') }}"
data-test="mobile-menu-button"
>
<svg x-show="!mobileMenuOpen" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg x-show="mobileMenuOpen" x-cloak class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Mobile Menu -->
<!-- Mobile menu sheet -->
<div
x-show="mobileMenuOpen"
x-trap.inert.noscroll="mobileMenuOpen"
@@ -148,102 +85,32 @@
x-cloak
@click.away="mobileMenuOpen = false"
@keydown.escape.window="mobileMenuOpen = false"
class="md:hidden bg-primary border-t border-cta/20"
class="menu-sheet"
role="dialog"
aria-modal="true"
aria-label="{{ __('Mobile navigation menu') }}"
aria-label="{{ __('site.nav_menu') }}"
data-test="mobile-menu"
>
<div class="px-4 py-3 space-y-1">
@foreach ($links as $link)
<a
href="{{ route('home') }}"
@class([
'block px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center',
'bg-active border-s-2 border-cta' => request()->routeIs('home'),
])
href="{{ $link['href'] }}"
@if ($link['current']) aria-current="page" @endif
wire:navigate
@click="mobileMenuOpen = false"
data-test="mobile-nav-home"
>
{{ __('navigation.home') }}
</a>
<a
href="{{ route('about') }}"
@class([
'block px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center',
'bg-active border-s-2 border-cta' => request()->routeIs('about'),
])
wire:navigate
@click="mobileMenuOpen = false"
data-test="mobile-nav-about"
>
{{ __('navigation.about') }}
</a>
<a
href="{{ route('booking') }}"
@class([
'block px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center',
'bg-active border-s-2 border-cta' => request()->routeIs('booking*'),
])
wire:navigate
@click="mobileMenuOpen = false"
data-test="mobile-nav-booking"
>
{{ __('navigation.booking') }}
</a>
<a
href="{{ route('posts.index') }}"
@class([
'block px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center',
'bg-active border-s-2 border-cta' => request()->routeIs('posts*'),
])
wire:navigate
@click="mobileMenuOpen = false"
data-test="mobile-nav-posts"
>
{{ __('navigation.posts') }}
</a>
data-test="mobile-nav-{{ $link['key'] }}"
>{{ $link['label'] }}</a>
@endforeach
@auth
@php
$dashboardRoute = auth()->user()->isAdmin() ? route('admin.dashboard') : route('client.dashboard');
@endphp
<a
href="{{ $dashboardRoute }}"
@class([
'block px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center',
'bg-active border-s-2 border-cta' => request()->routeIs('*.dashboard'),
])
wire:navigate
@click="mobileMenuOpen = false"
data-test="mobile-nav-dashboard"
>
{{ __('navigation.dashboard') }}
</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button
type="submit"
class="w-full text-start px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center"
data-test="mobile-nav-logout"
>
{{ __('navigation.logout') }}
</button>
</form>
@else
<a
href="{{ route('login') }}"
@class([
'block px-3 py-3 text-off-white hover:text-cta hover:bg-primary-light rounded-md min-h-[44px] flex items-center',
'bg-active border-s-2 border-cta' => request()->routeIs('login'),
])
wire:navigate
@click="mobileMenuOpen = false"
data-test="mobile-nav-login"
>
{{ __('navigation.login') }}
</a>
@endauth
</div>
@auth
<a href="{{ $dashboardRoute }}" wire:navigate @click="mobileMenuOpen = false" data-test="mobile-nav-dashboard">{{ __('navigation.dashboard') }}</a>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" data-test="mobile-nav-logout">{{ __('navigation.logout') }}</button>
</form>
@else
<a href="{{ route('login') }}" wire:navigate @click="mobileMenuOpen = false" data-test="mobile-nav-login">{{ __('site.nav_login') }}</a>
@endauth
<a class="btn btn-gold" href="{{ route('home') }}#book" @click="mobileMenuOpen = false">{{ __('site.nav_book') }}</a>
</div>
</nav>
</header>
@@ -0,0 +1,10 @@
@props(['mode' => 'static', 'label' => null, 'size' => null])
<svg
{{ $attributes->merge(['class' => 'site-balance']) }}
data-balance="{{ $mode }}"
viewBox="0 0 400 400"
style="overflow:visible"
@if ($size) width="{{ $size }}" height="{{ $size }}" @endif
@if ($label) role="img" aria-label="{{ $label }}" @else aria-hidden="true" @endif
></svg>
@@ -0,0 +1,10 @@
@props(['post'])
<a class="post" href="{{ route('posts.show', $post) }}" wire:navigate data-reveal>
<div class="bd">
<span class="meta" dir="auto">{{ $post->published_at?->translatedFormat('j F Y') }}</span>
<h3>{{ $post->getTitle() }}</h3>
<p>{{ \Illuminate\Support\Str::limit(strip_tags($post->getBody()), 140) }}</p>
<span class="more">{{ __('home.read_more') }}</span>
</div>
</a>
@@ -0,0 +1,13 @@
@props(['label', 'title', 'lede' => null, 'tag' => 'h2'])
<div class="sec-head">
<div>
<span class="art-no" data-reveal>{{ $label }}</span>
<{{ $tag }} data-reveal data-d="1">{{ $title }}</{{ $tag }}>
</div>
@if ($lede)
<p data-reveal data-d="2">{{ $lede }}</p>
@else
{{ $slot }}
@endif
</div>
@@ -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,20 +196,56 @@ 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">
<flux:button size="sm" wire:click="previousMonth" variant="outline" class="site-cal-nav">
<flux:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'right' : 'left' }}" class="size-5" />
</flux:button>
<flux:heading size="lg" class="text-body">{{ $monthName }}</flux:heading>
<flux:button size="sm" wire:click="nextMonth" variant="outline">
<flux:button size="sm" wire:click="nextMonth" variant="outline" class="site-cal-nav">
<flux:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'left' : 'right' }}" class="size-5" />
</flux:button>
</div>
@@ -187,7 +268,10 @@ new class extends Component
<button
wire:click="selectDate('{{ $dayData['date'] }}')"
@class([
'calendar-cell rounded-lg text-center transition-colors font-medium min-h-[44px] text-white',
'calendar-cell site-cal-day rounded-lg text-center transition-colors font-medium min-h-[44px] text-white',
'is-available' => in_array($dayData['status'], ['available', 'partial']),
'is-selected' => $selectedDate === $dayData['date'],
'is-unavailable' => in_array($dayData['status'], ['past', 'closed', 'blocked', 'full', 'user_booked']),
'bg-[#4A5D23]/70 hover:bg-[#4A5D23]/85' => $dayData['status'] === 'available',
'bg-[#D4A84B]/70 hover:bg-[#D4A84B]/85' => $dayData['status'] === 'partial',
'bg-sky-400/50 cursor-not-allowed' => $dayData['status'] === 'user_booked',
@@ -248,4 +332,5 @@ new class extends Component
<span class="text-zinc-600">{{ __('booking.unavailable') }}</span>
</div>
</div>
@endif
</div>
+86 -261
View File
@@ -9,286 +9,111 @@ new #[Layout('components.layouts.public')] class extends Component
}; ?>
<div>
{{-- Hero Section --}}
<section class="bg-background py-10 sm:py-[60px] lg:py-20 -mx-4 sm:-mx-6 lg:-mx-8 -mt-6 sm:-mt-8">
<div class="container mx-auto px-4 text-center">
{{-- Page Title --}}
<h1 class="text-[2rem] sm:text-[2.5rem] lg:text-[3rem] font-bold text-text mb-2">
{{ __('about.title') }}
</h1>
{{-- Tagline --}}
<p class="text-xl sm:text-2xl text-text/80 max-w-[800px] mx-auto mb-4">
{{ __('about.tagline') }}
</p>
{{-- Identity Statement --}}
<p class="text-lg text-text/70 max-w-[700px] mx-auto">
{{ __('about.identity') }}
</p>
{{-- Hero --}}
<section class="sec" style="padding-bottom:64px">
<div class="wrap" style="max-width:52em">
<span class="eyebrow" data-reveal>{{ __('about.tagline') }}</span>
<h1 data-reveal data-d="1" style="font-size:clamp(2.6rem,4.6vw,4.1rem);line-height:1.12;margin:18px 0 22px">{{ __('about.title') }}</h1>
<p class="lede" data-reveal data-d="2">{{ __('about.identity') }}</p>
</div>
</section>
{{-- Vision Section --}}
<section class="bg-white py-8 sm:py-12 lg:py-[60px]">
<div class="container mx-auto px-4">
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
{{ __('about.vision_title') }}
</h2>
<div class="w-20 h-1 bg-accent mx-auto mb-8"></div>
<blockquote class="max-w-[900px] mx-auto text-center text-lg text-text/90">
<span class="text-4xl text-accent leading-none align-top">"</span>
<span class="italic">{{ __('about.vision_text') }}</span>
<span class="text-4xl text-accent leading-none align-bottom">"</span>
</blockquote>
</div>
</section>
{{-- Mission Section --}}
<section class="bg-background py-8 sm:py-12 lg:py-[60px]">
<div class="container mx-auto px-4">
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
{{ __('about.mission_title') }}
</h2>
<div class="w-20 h-1 bg-accent mx-auto mb-8"></div>
<div class="bg-white rounded-lg shadow-sm p-6 sm:p-8 max-w-[900px] mx-auto mb-12">
<p class="text-center text-lg text-text/90">
{{ __('about.mission_intro') }}
</p>
{{-- Vision and mission --}}
<section class="sec fabric">
<div class="wrap">
<div class="fabric-grid">
<div data-reveal>
<span class="art-no">{{ __('site.about_art_vision') }}</span>
<h2>{{ __('about.vision_title') }}</h2>
<p class="big">{{ __('about.vision_text') }}</p>
<h3 style="margin-top:28px">{{ __('about.mission_title') }}</h3>
<p>{{ __('about.mission_intro') }}</p>
</div>
<div class="hoop" data-reveal data-d="2"><img src="{{ asset('images/site/embroidery-hoop.jpg') }}" alt="{{ __('site.alt_hoop') }}" width="773" height="1000"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{{-- Card 1: For Institution --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="text-accent mb-4">
<flux:icon name="building-office" class="w-12 h-12" />
</div>
<h3 class="text-xl font-bold text-text mb-3">
{{ __('about.mission_institution_title') }}
</h3>
<p class="text-text/80">
{{ __('about.mission_institution_text') }}
</p>
</div>
{{-- Card 2: For Society --}}
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="text-accent mb-4">
<flux:icon name="user-group" class="w-12 h-12" />
</div>
<h3 class="text-xl font-bold text-text mb-3">
{{ __('about.mission_society_title') }}
</h3>
<p class="text-text/80">
{{ __('about.mission_society_text') }}
</p>
</div>
{{-- Card 3: For Diaspora --}}
<div class="bg-white rounded-lg shadow-sm p-6 md:col-span-2 lg:col-span-1">
<div class="text-accent mb-4">
<flux:icon name="globe-alt" class="w-12 h-12" />
</div>
<h3 class="text-xl font-bold text-text mb-3">
{{ __('about.mission_diaspora_title') }}
</h3>
<p class="text-text/80">
{{ __('about.mission_diaspora_text') }}
</p>
</div>
<div class="three">
<article class="card" data-reveal>
<div class="ph motif-ph"><x-site.balance mode="static" :size="150" /><span class="lbl">{{ __('site.card_institution_label') }}</span></div>
<div class="bd"><h3>{{ __('about.mission_institution_title') }}</h3><p>{{ __('about.mission_institution_text') }}</p></div>
</article>
<article class="card" data-reveal data-d="1">
<div class="ph"><img src="{{ asset('images/site/olives.jpg') }}" alt="{{ __('site.alt_olives') }}" width="900" height="1350"><span class="lbl">{{ __('site.card_society_label') }}</span></div>
<div class="bd"><h3>{{ __('about.mission_society_title') }}</h3><p>{{ __('about.mission_society_text') }}</p></div>
</article>
<article class="card" data-reveal data-d="2">
<div class="ph"><img src="{{ asset('images/site/hills.jpg') }}" alt="{{ __('site.alt_hills') }}" width="900" height="1600"><span class="lbl">{{ __('site.card_diaspora_label') }}</span></div>
<div class="bd"><h3>{{ __('about.mission_diaspora_title') }}</h3><p>{{ __('about.mission_diaspora_text') }}</p></div>
</article>
</div>
</div>
</section>
{{-- Values Section --}}
<section class="bg-white py-8 sm:py-12 lg:py-[60px]">
<div class="container mx-auto px-4">
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
{{ __('about.values_title') }}
</h2>
<div class="w-20 h-1 bg-accent mx-auto mb-4"></div>
<p class="text-center text-text/70 max-w-xl mx-auto mb-12">
{{ __('about.values_intro') }}
</p>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 lg:gap-6">
{{-- Integrity --}}
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
<div class="text-accent mb-3">
<flux:icon name="shield-check" class="w-10 h-10 mx-auto" />
{{-- Values --}}
<section class="sec">
<div class="wrap">
<x-site.section-head :label="__('site.about_art_values')" :title="__('about.values_title')" :lede="__('about.values_intro')" />
@php
$valueIcons = [
'<path d="M12 3l7 3v6c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6z"/><path d="M9 12l2 2 4-4"/>',
'<path d="M12 4v16M8 20h8M5 7h14"/><path d="M5 7l-3 6a3 3 0 0 0 6 0zM19 7l-3 6a3 3 0 0 0 6 0z"/>',
'<path d="M2 5h6a4 4 0 0 1 4 4v11a3 3 0 0 0-3-3H2zM22 5h-6a4 4 0 0 0-4 4v11a3 3 0 0 1 3-3h7z"/>',
'<path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z"/>',
'<rect x="3" y="7.5" width="18" height="12" rx="2"/><path d="M9 7.5V5h6v2.5M3 13h18"/>',
'<path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-4 10.5c.8.8 1 1.5 1 2.5h6c0-1 .2-1.7 1-2.5A6 6 0 0 0 12 3z"/>',
];
@endphp
<div class="sampler">
@foreach (['integrity', 'justice', 'knowledge', 'women', 'professionalism', 'innovation'] as $i => $value)
<div class="val" data-reveal @if ($i) data-d="{{ $i }}" @endif>
<span class="medal"><svg viewBox="0 0 24 24" aria-hidden="true">{!! $valueIcons[$i] !!}</svg></span>
<b>{{ __('about.value_'.$value) }}</b>
</div>
<h4 class="font-semibold text-text">
{{ __('about.value_integrity') }}
</h4>
</div>
{{-- Justice --}}
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
<div class="text-accent mb-3">
<flux:icon name="scale" class="w-10 h-10 mx-auto" />
</div>
<h4 class="font-semibold text-text">
{{ __('about.value_justice') }}
</h4>
</div>
{{-- Knowledge --}}
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
<div class="text-accent mb-3">
<flux:icon name="academic-cap" class="w-10 h-10 mx-auto" />
</div>
<h4 class="font-semibold text-text">
{{ __('about.value_knowledge') }}
</h4>
</div>
{{-- Women's Empowerment --}}
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
<div class="text-accent mb-3">
<flux:icon name="heart" class="w-10 h-10 mx-auto" />
</div>
<h4 class="font-semibold text-text">
{{ __('about.value_women') }}
</h4>
</div>
{{-- Professionalism --}}
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
<div class="text-accent mb-3">
<flux:icon name="briefcase" class="w-10 h-10 mx-auto" />
</div>
<h4 class="font-semibold text-text">
{{ __('about.value_professionalism') }}
</h4>
</div>
{{-- Social Innovation --}}
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
<div class="text-accent mb-3">
<flux:icon name="light-bulb" class="w-10 h-10 mx-auto" />
</div>
<h4 class="font-semibold text-text">
{{ __('about.value_innovation') }}
</h4>
</div>
@endforeach
</div>
</div>
</section>
{{-- Goals Section --}}
<section class="bg-background py-8 sm:py-12 lg:py-[60px]">
<div class="container mx-auto px-4">
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
{{ __('about.goals_title') }}
</h2>
<div class="w-20 h-1 bg-accent mx-auto mb-12"></div>
{{-- Goals --}}
<section class="sec" style="padding-top:20px">
<div class="wrap">
<x-site.section-head :label="__('site.about_art_goals')" :title="__('about.goals_title')" />
<ol class="docket" style="list-style:none;margin:0;padding:0">
@foreach (__('about.goals') as $i => $goal)
<li class="row row-static" data-reveal>
<span class="n">{{ sprintf('%02d', $i + 1) }}</span>
<p style="font-size:1.1rem;color:var(--ink);margin:0">{{ $goal }}</p>
</li>
@endforeach
</ol>
</div>
</section>
<div class="bg-white rounded-lg shadow-sm p-6 sm:p-8 max-w-5xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6">
@foreach(__('about.goals') as $index => $goal)
<div class="flex items-start gap-4">
<span class="flex-shrink-0 w-8 h-8 bg-accent text-white rounded-full flex items-center justify-center font-bold text-sm">
{{ $index + 1 }}
</span>
<p class="text-text/90 pt-1">{{ $goal }}</p>
{{-- Legal services --}}
<section class="sec" id="services" style="padding-top:20px">
<div class="wrap">
<x-site.section-head :label="__('site.about_art_services')" :title="__('about.services_title')" :lede="__('about.services_intro')" />
<div class="docket">
@foreach (['consultation', 'family', 'civil', 'business'] as $i => $service)
<a class="row" href="{{ route('booking') }}" wire:navigate data-reveal @if ($i) data-d="{{ $i }}" @endif>
<span class="n">{{ sprintf('%02d', $i + 1) }}</span>
<h3>{{ __('about.service_'.$service.'_title') }}</h3>
<div class="chips">
@foreach (__('about.service_'.$service.'_items') as $item)
<span class="chip">{{ $item }}</span>
@endforeach
</div>
@endforeach
</div>
<span class="go"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"/></svg></span>
</a>
@endforeach
</div>
</div>
</section>
{{-- Services Section --}}
<section class="bg-white py-8 sm:py-12 lg:py-[60px]">
<div class="container mx-auto px-4">
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
{{ __('about.services_title') }}
</h2>
<div class="w-20 h-1 bg-accent mx-auto mb-4"></div>
<p class="text-center text-text/70 max-w-3xl mx-auto mb-12">
{{ __('about.services_intro') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
{{-- Legal Consultations & Representation --}}
<div class="bg-background rounded-xl p-8 text-center shadow-sm">
<div class="text-accent mb-4">
<flux:icon name="scale" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('about.service_consultation_title') }}
</h3>
<ul class="text-sm text-text/80 space-y-2 text-start">
@foreach(__('about.service_consultation_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-accent rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
{{-- Women's & Family Law --}}
<div class="bg-background rounded-xl p-8 text-center shadow-sm">
<div class="text-accent mb-4">
<flux:icon name="heart" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('about.service_family_title') }}
</h3>
<ul class="text-sm text-text/80 space-y-2 text-start">
@foreach(__('about.service_family_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-accent rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
{{-- Civil & Commercial Disputes --}}
<div class="bg-background rounded-xl p-8 text-center shadow-sm">
<div class="text-accent mb-4">
<flux:icon name="building-office" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('about.service_civil_title') }}
</h3>
<ul class="text-sm text-text/80 space-y-2 text-start">
@foreach(__('about.service_civil_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-accent rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
{{-- Business Legal Services --}}
<div class="bg-background rounded-xl p-8 text-center shadow-sm">
<div class="text-accent mb-4">
<flux:icon name="briefcase" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('about.service_business_title') }}
</h3>
<ul class="text-sm text-text/80 space-y-2 text-start">
@foreach(__('about.service_business_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-accent rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
</div>
<div class="text-center">
<flux:button variant="primary" href="{{ route('booking') }}">
{{ __('about.services_cta') }}
</flux:button>
</div>
{{-- Closing call to action --}}
<section class="band on-dark">
<div class="wrap" style="text-align:center;padding-block:72px">
<h2 style="color:var(--cream)" data-reveal>{{ __('site.about_cta_title') }}</h2>
<p style="margin-top:22px" data-reveal data-d="1"><a class="btn btn-gold" href="{{ route('booking') }}" wire:navigate>{{ __('about.services_cta') }}</a></p>
</div>
</section>
</div>
@@ -8,18 +8,13 @@ new #[Layout('components.layouts.public')] class extends Component
//
}; ?>
<div class="max-w-2xl mx-auto py-16 px-4 text-center">
<flux:icon name="check-circle" class="w-16 h-16 mx-auto text-green-500 mb-6" />
<flux:heading size="xl" class="mb-4 text-body">
{{ __('booking.success_title') }}
</flux:heading>
<p class="text-zinc-600 mb-6">
{{ __('booking.success_message') }}
</p>
<flux:button href="{{ route('home') }}">
{{ __('navigation.home') }}
</flux:button>
</div>
<section class="sec">
<div class="wrap">
<div class="success-card">
<x-site.balance mode="static" style="width:120px;height:120px;margin:0 auto 18px" />
<h1>{{ __('booking.success_title') }}</h1>
<p>{{ __('booking.success_message') }}</p>
<a class="btn btn-ghost" href="{{ route('home') }}" wire:navigate>{{ __('site.nav_home') }}</a>
</div>
</div>
</section>
+177 -162
View File
@@ -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,178 +196,189 @@ new #[Layout('components.layouts.public')] class extends Component
}
}; ?>
<div class="max-w-4xl mx-auto py-8 px-4">
<flux:heading size="xl" class="mb-6 text-body">
{{ __('booking.request_consultation') }}
</flux:heading>
@if($isLoggedIn)
{{-- Logged-in user message --}}
<div class="bg-white rounded-lg shadow-sm border border-amber-200 p-8 text-center">
<div class="mb-6">
<div class="mx-auto w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mb-4">
<flux:icon name="user-circle" class="w-8 h-8 text-amber-600" />
</div>
<flux:heading size="lg" class="text-body mb-2">
{{ __('booking.logged_in_title') }}
</flux:heading>
<p class="text-zinc-600 max-w-md mx-auto">
{{ __('booking.logged_in_message') }}
</p>
</div>
<div class="flex flex-col sm:flex-row gap-3 justify-center">
<flux:button
href="{{ route('client.consultations.book') }}"
variant="primary"
class="min-h-[44px]"
>
<flux:icon name="calendar" class="w-4 h-4 me-2" />
{{ __('booking.go_to_dashboard') }}
</flux:button>
</div>
<p class="mt-6 text-sm text-zinc-500">
{{ __('booking.or_logout') }}
</p>
</div>
@else
<div>
@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'))
<flux:callout variant="success" class="mb-6">
{{ session('success') }}
</flux:callout>
<p class="ok" role="status">{{ session('success') }}</p>
@endif
@if(!$selectedDate || !$selectedTime)
{{-- Step 1: Calendar Selection --}}
{{-- Booking Expectations --}}
<x-booking-expectations class="mb-6" />
<p class="mb-4 text-zinc-600">
{{ __('booking.select_date_time') }}
</p>
<livewire:availability-calendar />
@else
{{-- Step 2+: Contact Form & Confirmation --}}
<div class="bg-amber-50 p-4 rounded-lg mb-6 border border-amber-200">
<div class="flex justify-between items-center">
<div>
<p class="font-semibold text-zinc-900">
{{ __('booking.selected_time') }}
</p>
<p class="text-zinc-600">
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}
</p>
<p class="text-zinc-600">
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}
</p>
</div>
<flux:button size="sm" wire:click="clearSelection">
{{ __('common.change') }}
</flux:button>
<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>
@if(!$showConfirmation)
{{-- Contact Form --}}
<div class="space-y-4">
<flux:field>
<flux:label class="required">{{ __('booking.guest_name') }}</flux:label>
<flux:input wire:model="guestName" type="text" />
<flux:error name="guestName" />
</flux:field>
<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>
<flux:field>
<flux:label class="required">{{ __('booking.guest_email') }}</flux:label>
<flux:input wire:model="guestEmail" type="email" />
<flux:error name="guestEmail" />
</flux:field>
<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>
<flux:field>
<flux:label class="required">{{ __('booking.guest_phone') }}</flux:label>
<flux:input wire:model="guestPhone" type="tel" />
<flux:error name="guestPhone" />
</flux:field>
<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>
<flux:field>
<flux:label class="required">{{ __('booking.problem_summary') }}</flux:label>
<flux:textarea
wire:model="problemSummary"
rows="6"
placeholder="{{ __('booking.problem_summary_placeholder') }}"
/>
<flux:description>{{ __('booking.problem_summary_help') }}</flux:description>
<flux:error name="problemSummary" />
</flux:field>
<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>
{{-- Custom Captcha --}}
<flux:field>
<flux:label class="required">
{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }}
</flux:label>
<div class="flex gap-2">
<flux:input wire:model="captchaAnswer" type="text" class="w-32" />
<flux:button size="sm" wire:click="refreshCaptcha" type="button">
<flux:icon name="arrow-path" class="w-4 h-4" />
</flux:button>
<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>
<flux:error name="captchaAnswer" />
</flux:field>
<flux:button
wire:click="showConfirm"
class="w-full sm:w-auto min-h-[44px]"
wire:loading.attr="disabled"
>
<span wire:loading.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
</flux:button>
</div>
@else
{{-- Confirmation Step --}}
<flux:callout>
<flux:heading size="sm" class="text-body">{{ __('booking.confirm_booking') }}</flux:heading>
<p class="text-zinc-600">{{ __('booking.confirm_message') }}</p>
@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>
<div class="mt-4 space-y-2">
<p><strong>{{ __('booking.guest_name') }}:</strong> {{ $guestName }}</p>
<p><strong>{{ __('booking.guest_email') }}:</strong> {{ $guestEmail }}</p>
<p><strong>{{ __('booking.guest_phone') }}:</strong> {{ $guestPhone }}</p>
<p><strong>{{ __('booking.date') }}:</strong>
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
<p><strong>{{ __('booking.time') }}:</strong>
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
<p><strong>{{ __('booking.duration') }}:</strong> 45 {{ __('common.minutes') }}</p>
</div>
<div class="mt-4">
<p><strong>{{ __('booking.problem_summary') }}:</strong></p>
<p class="mt-1 text-sm text-zinc-600">{{ $problemSummary }}</p>
</div>
</flux:callout>
<div class="flex flex-col sm:flex-row gap-3 mt-4">
<flux:button wire:click="$set('showConfirmation', false)" class="w-full sm:w-auto min-h-[44px]">
{{ __('common.back') }}
</flux:button>
<flux:button
wire:click="submit"
variant="primary"
wire:loading.attr="disabled"
class="w-full sm:w-auto min-h-[44px]"
>
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
</flux:button>
</div>
@error('selectedTime')
<flux:callout variant="danger" class="mt-4">
{{ $message }}
</flux:callout>
@enderror
@endif
@endif
@endif
<x-booking-expectations class="mt-10" />
</div>
</section>
@endif
</div>
+148 -194
View File
@@ -18,220 +18,174 @@ new #[Layout('components.layouts.public')] class extends Component
}; ?>
<div>
{{-- Hero Section --}}
<section class="bg-background py-8 sm:py-12 lg:py-16">
<div class="container mx-auto px-4 text-center">
{{-- Tagline --}}
<h1 class="text-[1.75rem] sm:text-[2rem] lg:text-[2.5rem] font-bold text-text mb-2">
{{ __('home.tagline') }}
</h1>
{{-- Introductory Text --}}
<p class="text-text text-base sm:text-lg max-w-[800px] mx-auto mb-6">
{{ __('home.intro') }}
</p>
{{-- CTA Buttons --}}
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ route('booking') }}" class="btn-primary">
{{ __('home.cta_book') }}
</a>
<a href="#services" class="btn-secondary">
{{ __('home.cta_services') }}
</a>
{{-- Hero --}}
<section class="hero" id="top">
<div class="wrap">
<div>
<span class="eyebrow" data-reveal>{{ __('site.hero_eyebrow') }}</span>
<h1 data-reveal data-d="1">{{ __('site.hero_title_a') }}<em>{{ __('site.hero_title_em') }}</em>{{ __('site.hero_title_b') }}</h1>
<p class="lede" data-reveal data-d="2">{{ __('home.intro') }}</p>
<div class="cta-row" data-reveal data-d="3">
<a class="btn btn-gold" href="#book"><span>{{ __('home.cta_book') }}</span>
<svg class="arr" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"/></svg></a>
<a class="btn btn-ghost" href="#services"><span>{{ __('home.cta_services') }}</span></a>
</div>
<div class="trust" data-reveal data-d="4">
@foreach ([1, 2, 3] as $n)
<div><strong>{{ __('site.trust_'.$n.'_title') }}</strong><span>{{ __('site.trust_'.$n.'_text') }}</span></div>
@endforeach
</div>
</div>
<div class="hero-art" data-reveal data-d="2">
<figure class="frame" style="margin:0">
<img src="{{ asset('images/site/olive-tree.jpg') }}" alt="{{ __('site.alt_olive_tree') }}" width="800" height="1200">
<span class="glow"></span>
<x-site.balance mode="hero" :label="__('site.balance_label')" id="scales" />
<figcaption>{{ __('site.frame_caption') }}</figcaption>
</figure>
<div class="tag"><span class="dot"></span><span>{{ __('site.frame_tag') }}</span></div>
</div>
</div>
</section>
{{-- About Section --}}
<section id="about" class="py-16 lg:py-20 bg-card">
<div class="container mx-auto px-4">
<h2 class="text-2xl lg:text-3xl font-semibold text-text text-center mb-12">
{{ __('home.about_title') }}
</h2>
<div class="flex flex-col lg:flex-row items-center gap-8 lg:gap-12 max-w-5xl mx-auto">
{{-- Photo --}}
<div class="flex-shrink-0">
<img
src="{{ asset('images/huda-armouche.jpg') }}"
alt="{{ __('home.lawyer_name') }}"
class="w-48 h-48 lg:w-64 lg:h-64 rounded-full object-cover shadow-lg"
>
{{-- Pillars band --}}
<section class="band on-dark" aria-label="{{ __('site.pillars_label') }}">
<div class="wrap pillars">
@foreach (['justice', 'dignity', 'advocacy'] as $i => $pillar)
<div class="pillar" data-reveal @if ($i) data-d="{{ $i }}" @endif>
<div class="k"><span class="gem"></span><span>{{ __('site.pillar_'.$pillar) }}</span></div>
<p>{{ __('site.pillar_'.$pillar.'_text') }}</p>
</div>
@endforeach
</div>
</section>
{{-- Bio --}}
<div class="text-center lg:text-start">
<h3 class="text-xl lg:text-2xl font-bold text-text mb-1">
{{ __('home.lawyer_name') }}
</h3>
<p class="text-cta font-medium mb-4">
{{ __('home.lawyer_title') }}
</p>
<p class="text-body leading-relaxed">
{{ __('home.lawyer_bio') }}
</p>
<p class="text-body leading-relaxed mt-4">
{{ __('home.lawyer_bio_2') }}
</p>
</div>
{{-- Founder --}}
<section class="sec" id="founder">
<div class="wrap founder">
<div class="portrait" data-reveal>
<img src="{{ asset('images/huda-armouche.jpg') }}" alt="{{ __('home.lawyer_name') }}">
<div class="namecard"><b>{{ __('home.lawyer_name') }}</b><span>{{ __('site.founder_role') }}</span></div>
</div>
<div>
<span class="art-no" data-reveal>{{ __('site.art_founder') }}</span>
<h2 style="font-size:clamp(2rem,3.4vw,3rem);margin-top:10px" data-reveal data-d="1">{{ __('home.about_title') }}</h2>
<blockquote data-reveal data-d="2">{{ __('site.founder_quote') }}</blockquote>
<p data-reveal data-d="3">{{ __('home.lawyer_bio') }}</p>
<p data-reveal data-d="4">{{ __('home.lawyer_bio_2') }}</p>
<div class="sign" data-reveal data-d="5"><a class="btn btn-ghost" href="{{ route('about') }}" wire:navigate><span>{{ __('site.founder_cta') }}</span></a></div>
</div>
</div>
</section>
{{-- Services Section --}}
<section id="services" class="py-16 lg:py-20 bg-background">
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-2xl lg:text-3xl font-semibold text-text mb-4">
{{ __('home.services_title') }}
</h2>
<p class="text-body max-w-2xl mx-auto">
{{ __('home.services_subtitle') }}
</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{{-- Legal Consultations & Representation --}}
<div class="bg-card p-6 rounded-lg shadow-card text-center">
<div class="text-cta mb-4">
<flux:icon name="scale" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('home.service_consultation_title') }}
</h3>
<ul class="text-sm text-body space-y-2 text-start">
@foreach(__('home.service_consultation_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-cta rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
{{-- Women's & Family Law --}}
<div class="bg-card p-6 rounded-lg shadow-card text-center">
<div class="text-cta mb-4">
<flux:icon name="heart" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('home.service_family_title') }}
</h3>
<ul class="text-sm text-body space-y-2 text-start">
@foreach(__('home.service_family_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-cta rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
{{-- Civil & Commercial Disputes --}}
<div class="bg-card p-6 rounded-lg shadow-card text-center">
<div class="text-cta mb-4">
<flux:icon name="building-office" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('home.service_civil_title') }}
</h3>
<ul class="text-sm text-body space-y-2 text-start">
@foreach(__('home.service_civil_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-cta rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
{{-- Business Legal Services --}}
<div class="bg-card p-6 rounded-lg shadow-card text-center">
<div class="text-cta mb-4">
<flux:icon name="briefcase" class="w-12 h-12 mx-auto" />
</div>
<h3 class="text-lg font-bold text-text mb-4">
{{ __('home.service_business_title') }}
</h3>
<ul class="text-sm text-body space-y-2 text-start">
@foreach(__('home.service_business_items') as $item)
<li class="flex items-center gap-2">
<span class="w-1.5 h-1.5 bg-cta rounded-full flex-shrink-0"></span>
{{ $item }}
</li>
@endforeach
</ul>
</div>
</div>
</div>
</section>
{{-- Values Section --}}
<section id="values" class="py-16 lg:py-20 bg-card">
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-2xl lg:text-3xl font-semibold text-text mb-4">
{{ __('home.values_title') }}
</h2>
<p class="text-body max-w-2xl mx-auto">
{{ __('home.values_subtitle') }}
</p>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-6">
@foreach(['integrity', 'justice', 'knowledge', 'empowerment', 'professionalism', 'innovation'] as $value)
<div class="text-center group">
<div class="w-16 h-16 lg:w-20 lg:h-20 rounded-full bg-cta/10 flex items-center justify-center mx-auto mb-3 transition-colors group-hover:bg-cta/20">
<flux:icon name="{{ __('home.values.' . $value . '.icon') }}" class="w-8 h-8 lg:w-10 lg:h-10 text-cta" />
{{-- Services docket --}}
<section class="sec" id="services" style="padding-top:40px">
<div class="wrap">
<x-site.section-head :label="__('site.art_services')" :title="__('home.services_subtitle')" :lede="__('site.services_lede')" />
<div class="docket">
@foreach (['consultation', 'family', 'civil', 'business'] as $i => $service)
<a class="row" href="#book" data-reveal @if ($i) data-d="{{ $i }}" @endif>
<span class="n">{{ sprintf('%02d', $i + 1) }}</span>
<h3>{{ __('home.service_'.$service.'_title') }}</h3>
<div class="chips">
@foreach (__('home.service_'.$service.'_items') as $item)
<span class="chip">{{ $item }}</span>
@endforeach
</div>
<h3 class="text-sm lg:text-base font-bold text-text">
{{ __('home.values.' . $value . '.title') }}
</h3>
<span class="go"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6"/></svg></span>
</a>
@endforeach
</div>
</div>
</section>
{{-- Vision --}}
<section class="sec fabric" id="mission">
<div class="wrap">
<div class="fabric-grid">
<div data-reveal>
<span class="art-no">{{ __('site.art_vision') }}</span>
<h2>{{ __('site.vision_title') }}</h2>
<p class="big">{{ __('site.vision_text') }}</p>
</div>
<div class="hoop" data-reveal data-d="2"><img src="{{ asset('images/site/embroidery-hoop.jpg') }}" alt="{{ __('site.alt_hoop') }}" width="773" height="1000"></div>
</div>
<div class="three">
<article class="card" data-reveal>
<div class="ph motif-ph"><x-site.balance mode="static" :size="150" /><span class="lbl">{{ __('site.card_institution_label') }}</span></div>
<div class="bd"><h3>{{ __('site.card_institution_title') }}</h3><p>{{ __('site.card_institution_text') }}</p></div>
</article>
<article class="card" data-reveal data-d="1">
<div class="ph"><img src="{{ asset('images/site/olives.jpg') }}" alt="{{ __('site.alt_olives') }}" width="900" height="1350"><span class="lbl">{{ __('site.card_society_label') }}</span></div>
<div class="bd"><h3>{{ __('site.card_society_title') }}</h3><p>{{ __('site.card_society_text') }}</p></div>
</article>
<article class="card" data-reveal data-d="2">
<div class="ph"><img src="{{ asset('images/site/hills.jpg') }}" alt="{{ __('site.alt_hills') }}" width="900" height="1600"><span class="lbl">{{ __('site.card_diaspora_label') }}</span></div>
<div class="bd"><h3>{{ __('site.card_diaspora_title') }}</h3><p>{{ __('site.card_diaspora_text') }}</p></div>
</article>
</div>
</div>
</section>
{{-- Values --}}
@php
$valueIcons = [
'<path d="M12 3l7 3v6c0 4.5-3 7.6-7 9-4-1.4-7-4.5-7-9V6z"/><path d="M9 12l2 2 4-4"/>',
'<path d="M12 4v16M8 20h8M5 7h14"/><path d="M5 7l-3 6a3 3 0 0 0 6 0zM19 7l-3 6a3 3 0 0 0 6 0z"/>',
'<path d="M2 5h6a4 4 0 0 1 4 4v11a3 3 0 0 0-3-3H2zM22 5h-6a4 4 0 0 0-4 4v11a3 3 0 0 1 3-3h7z"/>',
'<path d="M12 20s-7-4.4-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.6-7 10-7 10z"/>',
'<rect x="3" y="7.5" width="18" height="12" rx="2"/><path d="M9 7.5V5h6v2.5M3 13h18"/>',
'<path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-4 10.5c.8.8 1 1.5 1 2.5h6c0-1 .2-1.7 1-2.5A6 6 0 0 0 12 3z"/>',
];
@endphp
<section class="sec" id="values">
<div class="wrap">
<x-site.section-head :label="__('site.art_values')" :title="__('home.values_title')" :lede="__('home.values_subtitle')" />
<div class="sampler">
@foreach (['integrity', 'justice', 'knowledge', 'empowerment', 'professionalism', 'innovation'] as $i => $value)
<div class="val" data-reveal @if ($i) data-d="{{ $i }}" @endif>
<span class="medal"><svg viewBox="0 0 24 24" aria-hidden="true">{!! $valueIcons[$i] !!}</svg></span>
<b>{{ __('home.values.'.$value.'.title') }}</b>
</div>
@endforeach
</div>
</div>
</section>
{{-- Latest Posts Section --}}
@if($latestPosts->count() > 0)
<section id="posts" class="py-16 lg:py-20 bg-background">
<div class="container mx-auto px-4">
<div class="text-center mb-12">
<h2 class="text-2xl lg:text-3xl font-semibold text-text mb-4">
{{ __('home.posts_title') }}
</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
@foreach($latestPosts as $post)
<article class="bg-card p-6 rounded-lg shadow-card hover:shadow-card-hover transition-shadow">
<time class="text-xs text-body/70 mb-2 block">
{{ $post->published_at?->translatedFormat('j F Y') ?? $post->created_at->translatedFormat('j F Y') }}
</time>
<h3 class="text-lg font-bold text-text mb-3">
<a href="{{ route('posts.show', $post) }}" class="hover:text-cta transition-colors" wire:navigate>
{{ $post->getTitle() }}
</a>
</h3>
<p class="text-body text-sm mb-4 line-clamp-3">
{{ $post->getExcerpt() }}
</p>
<a href="{{ route('posts.show', $post) }}" class="text-cta font-medium text-sm hover:text-cta-hover" wire:navigate>
{{ __('home.read_more') }} →
</a>
</article>
{{-- Latest articles --}}
<section class="sec" id="posts" style="padding-top:20px">
<div class="wrap">
<x-site.section-head :label="__('site.art_articles')" :title="__('home.posts_title')">
<a class="btn btn-ghost" href="{{ route('posts.index') }}" wire:navigate data-reveal data-d="2"><span>{{ __('home.view_all_posts') }}</span></a>
</x-site.section-head>
@if ($latestPosts->isNotEmpty())
<div class="posts">
@foreach ($latestPosts as $post)
<x-site.post-card :post="$post" />
@endforeach
</div>
@else
<p class="text-center" style="color:var(--muted)">{{ __('home.posts_empty') }}</p>
@endif
</div>
</section>
<div class="text-center">
<a href="{{ route('posts.index') }}" class="btn-secondary inline-flex items-center gap-2" wire:navigate>
{{ __('home.view_all_posts') }}
<span aria-hidden="true">→</span>
</a>
<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>
</section>
@endif
</div>
</section>
</div>
@@ -55,89 +55,44 @@ new #[Layout('components.layouts.public')] class extends Component
}
}; ?>
<div class="max-w-4xl mx-auto px-0">
<flux:heading size="xl" class="text-body text-xl sm:text-2xl lg:text-3xl">{{ __('posts.posts') }}</flux:heading>
<!-- Search Bar -->
<div class="mt-4 sm:mt-6 relative">
<flux:input
wire:model.live.debounce.300ms="search"
placeholder="{{ __('posts.search_placeholder') }}"
class="w-full min-h-[44px]"
>
<x-slot:iconLeading>
<flux:icon name="magnifying-glass" class="w-5 h-5 text-body/50" />
</x-slot:iconLeading>
</flux:input>
<section class="sec" style="padding-top:56px">
<div class="wrap">
<x-site.section-head tag="h1" :label="__('site.hero_eyebrow')" :title="__('site.nav_articles')">
<div style="position:relative">
<label class="sr-only" for="posts-search">{{ __('posts.search_placeholder') }}</label>
<input
id="posts-search"
type="search"
class="search"
wire:model.live.debounce.300ms="search"
placeholder="{{ __('posts.search_placeholder') }}"
>
</div>
</x-site.section-head>
@if($search)
<button
wire:click="clearSearch"
class="absolute end-3 top-1/2 -translate-y-1/2 text-body/50 hover:text-body min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<flux:icon name="x-mark" class="w-5 h-5" />
</button>
@endif
</div>
<!-- Search Results Info -->
@if($search)
<p class="mt-4 text-sm text-body/70">
@if($posts->total() > 0)
{{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
@else
{{ __('posts.no_results', ['query' => $search]) }}
@endif
</p>
@endif
<!-- Posts List -->
<div class="mt-6 sm:mt-8 space-y-4 sm:space-y-6">
@forelse($posts as $post)
<article wire:key="post-{{ $post->id }}" class="bg-card p-4 sm:p-6 rounded-lg shadow-card hover:shadow-card-hover transition-shadow">
<h2 class="text-lg sm:text-xl font-semibold text-text">
<a href="{{ route('posts.show', $post) }}" class="hover:text-cta" wire:navigate>
@if($search)
{!! $this->highlightSearch($post->getTitle(), $search) !!}
@else
{{ $post->getTitle() }}
@endif
</a>
</h2>
<time class="text-xs sm:text-sm text-body/70 mt-2 block">
{{ $post->published_at?->translatedFormat('d F Y') ?? $post->created_at->translatedFormat('d F Y') }}
</time>
<p class="mt-2 sm:mt-3 text-body text-sm sm:text-base">
@if($search)
{!! $this->highlightSearch($post->getExcerpt(), $search) !!}
@else
{{ $post->getExcerpt() }}
@endif
</p>
<a href="{{ route('posts.show', $post) }}" class="text-cta hover:text-cta-hover hover:underline mt-3 sm:mt-4 inline-flex items-center min-h-[44px]" wire:navigate>
{{ __('posts.read_more') }} &rarr;
</a>
</article>
@empty
<div class="empty-state bg-card rounded-lg">
@if($search)
<flux:icon name="magnifying-glass" class="empty-state-icon text-body/30" />
<p class="text-body/70">{{ __('posts.no_results', ['query' => $search]) }}</p>
<flux:button wire:click="clearSearch" class="mt-4">
{{ __('posts.clear_search') }}
</flux:button>
<p class="search-info">
@if($posts->total() > 0)
{{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
@else
<flux:icon name="document-text" class="empty-state-icon text-body/40" />
<p class="text-body/70">{{ __('posts.no_posts') }}</p>
{{ __('posts.no_results', ['query' => $search]) }}
@endif
</div>
@endforelse
</div>
<button type="button" class="btn btn-ghost" wire:click="clearSearch">{{ __('posts.clear_search') }}</button>
</p>
@endif
<div class="mt-8">
{{ $posts->links() }}
@if($posts->count() > 0)
<div class="posts posts-index">
@foreach($posts as $post)
<x-site.post-card :post="$post" wire:key="post-{{ $post->id }}" />
@endforeach
</div>
@elseif(! $search)
<p class="text-center" style="color:var(--muted)">{{ __('posts.no_posts') }}</p>
@endif
<div class="pagination-wrap">
{{ $posts->links() }}
</div>
</div>
</div>
</section>
@@ -18,22 +18,17 @@ new #[Layout('components.layouts.public')] class extends Component
}
}; ?>
<article class="max-w-3xl mx-auto">
<header class="mb-8">
<flux:heading size="xl" class="text-body">{{ $post->getTitle() }}</flux:heading>
<section class="sec" style="padding-top:56px">
<article class="wrap" style="max-width:768px">
<span class="meta">{{ $post->published_at?->translatedFormat('l, d F Y') ?? $post->created_at->translatedFormat('l, d F Y') }}</span>
<h1 style="font-size:clamp(2rem,3.4vw,3rem);margin:10px 0 28px">{{ $post->getTitle() }}</h1>
<time class="text-body/70 mt-2 block">
{{ $post->published_at?->translatedFormat('l, d F Y') ?? $post->created_at->translatedFormat('l, d F Y') }}
</time>
</header>
<div class="prose prose-lg prose-brand max-w-none site-prose">
{!! $post->getBody() !!}
</div>
<div class="prose prose-lg prose-brand max-w-none">
{!! $post->getBody() !!}
</div>
<footer class="mt-12 pt-6 border-t border-cta/20">
<a href="{{ route('posts.index') }}" class="text-cta hover:text-cta-hover hover:underline" wire:navigate>
&larr; {{ __('posts.back_to_posts') }}
</a>
</footer>
</article>
<footer style="border-top:1px solid var(--line);margin-top:48px;padding-top:24px">
<a class="btn btn-ghost" href="{{ route('posts.index') }}" wire:navigate>{{ __('posts.back_to_posts') }}</a>
</footer>
</article>
</section>
+14 -12
View File
@@ -1,15 +1,17 @@
<x-layouts.public>
<div class="py-8">
<h1 class="mb-6 text-3xl font-bold text-text">{{ $page->getTitle() }}</h1>
<div class="prose prose-brand max-w-none rounded-lg bg-card p-8 shadow-card" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
@if ($page->getContent())
{!! clean($page->getContent()) !!}
@else
<p class="text-body">{{ __('pages.content_coming_soon') }}</p>
@endif
<section class="sec" style="padding-top:56px">
<div class="wrap" style="max-width:768px">
<h1 style="font-size:clamp(2rem,3.4vw,3rem);margin:0 0 28px">{{ $page->getTitle() }}</h1>
<div class="prose prose-brand max-w-none site-prose legal-card" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
@if ($page->getContent())
{!! clean($page->getContent()) !!}
@else
<p>{{ __('pages.content_coming_soon') }}</p>
@endif
</div>
<p class="legal-updated">
{{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
</p>
</div>
<p class="mt-4 text-sm text-body/70">
{{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
</p>
</div>
</section>
</x-layouts.public>
+7 -5
View File
@@ -1,8 +1,10 @@
<x-layouts.public>
<div class="py-8">
<h1 class="text-3xl font-bold text-text mb-6">{{ __('footer.privacy') }}</h1>
<div class="bg-card p-8 rounded-lg shadow-card prose prose-brand max-w-none">
<p class="text-body">{{ __('Privacy policy content will be added here.') }}</p>
<section class="sec" style="padding-top:56px">
<div class="wrap" style="max-width:768px">
<h1 style="font-size:clamp(2rem,3.4vw,3rem);margin:0 0 28px">{{ __('footer.privacy') }}</h1>
<div class="prose prose-brand max-w-none site-prose legal-card">
<p>{{ __('Privacy policy content will be added here.') }}</p>
</div>
</div>
</div>
</section>
</x-layouts.public>
+7 -5
View File
@@ -1,8 +1,10 @@
<x-layouts.public>
<div class="py-8">
<h1 class="text-3xl font-bold text-text mb-6">{{ __('footer.terms') }}</h1>
<div class="bg-card p-8 rounded-lg shadow-card prose prose-brand max-w-none">
<p class="text-body">{{ __('Terms of service content will be added here.') }}</p>
<section class="sec" style="padding-top:56px">
<div class="wrap" style="max-width:768px">
<h1 style="font-size:clamp(2rem,3.4vw,3rem);margin:0 0 28px">{{ __('footer.terms') }}</h1>
<div class="prose prose-brand max-w-none site-prose legal-card">
<p>{{ __('Terms of service content will be added here.') }}</p>
</div>
</div>
</div>
</section>
</x-layouts.public>
+1 -1
View File
@@ -10,7 +10,7 @@
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#4A4A42">
<meta name="theme-color" content="#2D3624">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -0,0 +1,3 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Markazi+Text:wght@400;500;600;700&family=IBM+Plex+Sans+Arabic:wght@300;400;500;600&display=swap" rel="stylesheet">
@@ -0,0 +1,63 @@
<?php
function aboutHtml(string $locale): string
{
return test()->withSession(['locale' => $locale])->get('/about')->assertOk()->getContent();
}
test('about renders its sections in order with the existing content in both languages', function () {
foreach (['ar', 'en'] as $locale) {
$html = aboutHtml($locale);
app()->setLocale($locale);
$needles = [
__('about.title'), __('about.identity'), __('about.vision_title'), __('about.mission_institution_title'),
__('about.mission_society_title'), __('about.mission_diaspora_title'), __('about.values_title'), __('about.value_women'),
__('about.goals_title'), __('about.goals')[6], __('about.services_title'), __('about.service_business_items')[0],
__('site.about_cta_title'), __('about.services_cta'),
];
$last = -1;
foreach ($needles as $n) {
$pos = strpos($html, e($n), $last + 1);
$pos = $pos === false ? strpos($html, $n, $last + 1) : $pos;
expect($pos)->not->toBeFalse("missing or out of order [$locale]: $n");
$last = $pos;
}
expect(substr_count($html, 'class="sampler"'))->toBe(1);
expect(substr_count($html, 'class="val"'))->toBe(6);
expect(substr_count($html, 'row row-static'))->toBe(7);
}
});
test('about has one h1 and alt text on every image', function () {
$html = aboutHtml('ar');
expect(substr_count($html, '<h1'))->toBe(1);
preg_match_all('/<img\b[^>]*>/', $html, $m);
foreach ($m[0] as $img) {
expect($img)->toMatch('/\balt="[^"]+"/');
}
});
test('service rows link to booking and goal rows are static', function () {
$html = aboutHtml('en');
expect(substr_count($html, '<a class="row"'))->toBe(4);
expect(substr_count($html, 'href="'.route('booking').'"'))->toBeGreaterThanOrEqual(5);
expect(file_get_contents(resource_path('css/public.css')))
->toContain('body.site .row-static{grid-template-columns:90px 1fr}')
->toContain('body.site .row-static:hover{background:none;color:inherit;transform:none;box-shadow:none}')
->toContain('body.site .row-static::before{display:none}');
});
test('about page contains no scholarship content', function () {
expect(file_get_contents(resource_path('views/livewire/pages/about.blade.php')))->not->toContain('scholarship');
});
test('about site keys exist in both languages', function () {
foreach (['ar', 'en'] as $locale) {
$t = require base_path("lang/$locale/site.php");
foreach (['about_art_vision', 'about_art_values', 'about_art_goals', 'about_art_services', 'about_cta_title'] as $k) {
expect($t)->toHaveKey($k);
}
}
});
@@ -0,0 +1,86 @@
<?php
use App\Models\Page;
use App\Models\Post;
use App\Models\User;
use Livewire\Volt\Volt;
test('posts index shows the section head, search and text-only cards', function () {
Post::factory()->published()->count(3)->create();
$html = $this->withSession(['locale' => 'en'])->get('/posts')->assertOk()->getContent();
expect(substr_count($html, '<h1'))->toBe(1);
expect($html)
->toContain('class="sec-head"')
->toContain('wire:model.live.debounce.300ms="search"')
->toContain('class="search"')
->toContain('posts-index');
expect(substr_count($html, 'class="post"'))->toBe(3);
preg_match('~<div class="posts posts-index">(.*?)</section>~s', $html, $block);
expect($block[1])->not->toContain('<img');
});
test('search filters and shows the count line, with a clear action', function () {
Post::factory()->published()->create(['title' => ['en' => 'Alimony guide', 'ar' => 'دليل النفقة']]);
Post::factory()->published()->create(['title' => ['en' => 'Contracts', 'ar' => 'العقود']]);
app()->setLocale('en');
Volt::test('pages.posts.index')->set('search', 'Alimony')
->assertSee('Alimony guide')->assertDontSee('Contracts')
->assertSee(__('posts.search_results', ['count' => 1, 'query' => 'Alimony']))
->assertSee(__('posts.clear_search'))
->set('search', 'zzzz')->assertSee(__('posts.no_results', ['query' => 'zzzz']));
});
test('empty index shows the no posts message', function () {
Volt::test('pages.posts.index')->assertSee(__('posts.no_posts'));
});
test('pagination is wrapped and styled when there are more than ten posts', function () {
Post::factory()->published()->count(12)->create();
expect($this->get('/posts')->assertOk()->getContent())->toContain('pagination-wrap');
});
test('a published post shows meta, h1, body and a back link; unpublished is 404', function () {
$post = Post::factory()->published()->create();
$html = $this->get(route('posts.show', $post))->assertOk()->getContent();
expect($html)->toContain('class="meta"')->toContain('site-prose')->toContain(route('posts.index'))->toContain(__('posts.back_to_posts'));
expect(substr_count($html, '<h1'))->toBe(1);
$draft = Post::factory()->draft()->create();
$this->get(route('posts.show', $draft))->assertNotFound();
});
test('legal pages show title, sanitised content and the last updated line', function () {
Page::factory()->terms()->create(['content_en' => '<p>Hello</p><script>alert(1)</script>', 'content_ar' => '<p>مرحبا</p><script>alert(1)</script>']);
$html = $this->withSession(['locale' => 'en'])->get('/page/terms')->assertOk()->getContent();
expect($html)->toContain('Terms of Service')->toContain('Hello')->not->toContain('<script>alert(1)')->toContain('site-prose')
->toContain(__('pages.last_updated'));
expect(substr_count($html, '<h1'))->toBe(1);
});
test('terms and privacy views render with one h1', function () {
foreach (['terms', 'privacy'] as $v) {
$html = view("pages.$v")->render();
expect(substr_count($html, '<h1'))->toBe(1);
expect($html)->toContain('class="wrap"')->not->toContain('class="wrap py-8"');
}
});
test('admin posts do not load the public foundation', function () {
$html = $this->actingAs(User::factory()->admin()->create())->get('/admin/posts')->assertOk()->getContent();
expect($html)->not->toContain('public.css')->not->toContain('class="site');
});
test('articles css rules exist', function () {
expect(file_get_contents(resource_path('css/public.css')))
->toContain('body.site .posts-index .post:last-child{grid-column:auto}')
->toContain('body.site .pagination-wrap')
->toContain('body.site .site-prose a{color:var(--olive)}');
});
@@ -0,0 +1,62 @@
<?php
use App\Models\User;
test('auth pages load the public stylesheet and the site-auth body class but no public script', function () {
foreach (['/login', '/forgot-password'] as $url) {
$html = $this->get($url)->assertOk()->getContent();
expect($html)
->toContain('class="site-auth')
->toMatch('~build/assets/public-[\w-]+\.css~')
->not->toMatch('~build/assets/public-[\w-]+\.js~')
->toContain('Markazi+Text');
expect($html)->not->toContain('style="font-family: var(--font-');
}
});
test('all three auth layouts include the fonts, the stylesheet and the body class', function () {
foreach (['split', 'card', 'simple'] as $layout) {
$src = file_get_contents(resource_path("views/components/layouts/auth/$layout.blade.php"));
expect($src)
->toContain("@include('partials.site-fonts')")
->toContain("@vite(['resources/css/public.css'])")
->toContain('site-auth')
->not->toContain('style="font-family: var(--font-');
}
});
test('the auth styles are scoped under body.site-auth', function () {
$css = file_get_contents(resource_path('css/public.css'));
expect($css)
->toContain('body.site-auth{')
->toContain('body.site-auth input[type=email]')
->toContain('body.site-auth button[type=submit]')
->toContain('border-radius:999px');
});
test('dashboards carry neither the stylesheet nor site-auth', function () {
foreach ([[User::factory()->admin()->create(), '/admin/dashboard'], [User::factory()->individual()->create(), '/client/dashboard']] as [$user, $url]) {
$html = $this->actingAs($user)->get($url)->assertOk()->getContent();
expect($html)->not->toContain('public.css')->not->toContain('site-auth');
}
});
test('site fonts beat the app.css html[lang] body font and hidden Flux errors do not paint inputs red', function () {
$css = file_get_contents(resource_path('css/public.css'));
expect($css)
->toContain('html[lang] body.site{font-family:var(--body)}')
->toContain('html[lang] body.site-auth{font-family:"IBM Plex Sans Arabic"')
->toContain('[data-flux-field]:not(:has([data-flux-error]:not(.hidden))) input[data-flux-control]');
});
test('the auth link colour does not override the language toggle, whose active label is white on olive', function () {
$css = file_get_contents(resource_path('css/public.css'));
expect($css)
->toContain('body.site-auth main a:not([data-flux-button]):not([data-test^=language-switch]){color:#4A5D23}')
->toContain('body.site-auth main a:not([data-flux-button]):not([data-test^=language-switch]):hover{text-decoration:underline}');
});
@@ -0,0 +1,89 @@
<?php
use App\Models\Consultation;
use App\Models\User;
use App\Models\WorkingHour;
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');
foreach (range(0, 6) as $dow) {
WorkingHour::factory()->create(['day_of_week' => $dow, 'is_active' => true, 'start_time' => '09:00', 'end_time' => '13:00']);
}
});
test('booking page shows the section head, the forest card and the month calendar', function () {
$html = $this->get('/booking')->assertOk()->getContent();
expect(substr_count($html, '<h1'))->toBe(1);
expect($html)
->toContain('class="sec-head"')
->toContain('class="book"')
->toContain('book-l on-dark')
->toContain('data-balance="static"')
->toContain('tel:+970599353502')
->toContain('book-r')
->toContain('calendar-grid')
->not->toContain('class="days"');
expect(strpos($html, 'book-r'))->toBeLessThan(strpos($html, 'calendar-grid'));
});
test('selecting a slot reveals the fields and the full flow books once', function () {
$date = now()->addDay()->format('Y-m-d');
$c = Volt::test('pages.booking')
->call('selectSlot', $date, '09:00')
->assertSee('wire:model="guestName"', false)
->assertSee('wire:model="guestEmail"', false)
->assertSee('wire:model="guestPhone"', false)
->assertSee('wire:model="problemSummary"', false)
->assertSee('wire:model="captchaAnswer"', false)
->set('guestName', 'Test Guest')->set('guestEmail', 'g@example.com')->set('guestPhone', '+970599000000')
->set('problemSummary', 'A sufficiently long description of my case.')
->set('captchaAnswer', session('captcha_answer'))
->call('showConfirm')->assertSet('showConfirmation', true)
->call('submit')->assertRedirect(route('booking.success'));
expect(Consultation::count())->toBe(1);
});
test('validation errors render under their fields', function () {
$date = now()->addDay()->format('Y-m-d');
Volt::test('pages.booking')->call('selectSlot', $date, '09:00')
->call('showConfirm')
->assertHasErrors(['guestName', 'guestEmail', 'guestPhone', 'problemSummary', 'captchaAnswer'])
->assertSeeHtml('class="err"');
});
test('logged in clients see the message and no form on the booking page', function () {
$html = $this->actingAs(User::factory()->client()->create())->get('/booking')->assertOk()->getContent();
expect($html)->toContain(__('booking.logged_in_title'))->toContain(route('client.consultations.book'))
->not->toContain('wire:model="guestName"');
});
test('month calendar days carry hook classes without changing behaviour', function () {
$html = Volt::test('availability-calendar')->html();
expect($html)->toContain('site-cal-day')->toContain('is-available')->toContain('site-cal-nav')->toContain('wire:click="selectDate(');
});
test('calendar css is scoped to the public site', function () {
$css = file_get_contents(resource_path('css/public.css'));
expect($css)->toContain('body.site .book-r .site-cal-day')->toContain('body.site .book-r .site-cal-day.is-selected');
});
test('success page shows the balance, title, message and home button without flux output', function () {
$html = $this->get('/booking/success')->assertOk()->getContent();
expect($html)->toContain('data-balance="static"')->toContain(__('booking.success_title'))->toContain(__('booking.success_message'))
->toContain(__('site.nav_home'));
preg_match('~<main.*?</main>~s', $html, $main);
expect($main[0])->not->toContain('data-flux-');
});
@@ -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('class="days"')->not->toContain('wire:submit="showConfirm"')->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);
}
}
});
+96
View File
@@ -0,0 +1,96 @@
<?php
use App\Models\Post;
function homeHtml(string $locale): string
{
return test()->withSession(['locale' => $locale])->get('/')->assertOk()->getContent();
}
test('home renders the seven sections in order in both languages', function () {
foreach (['ar', 'en'] as $locale) {
$html = homeHtml($locale);
$positions = array_map(fn ($id) => strpos($html, 'id="'.$id.'"'), ['top', 'founder', 'services', 'mission', 'values', 'posts']);
$bandPos = strpos($html, 'class="band on-dark"');
foreach ($positions as $p) {
expect($p)->not->toBeFalse();
}
$sorted = $positions;
sort($sorted);
expect($positions)->toBe($sorted);
expect($bandPos)->toBeGreaterThan($positions[0])->toBeLessThan($positions[1]);
}
});
test('home copy comes from the site and home keys', function () {
$ar = homeHtml('ar');
expect($ar)->toContain(__('site.hero_title_em', [], 'ar'))->toContain(__('site.vision_title', [], 'ar'))
->toContain(__('home.services_subtitle', [], 'ar'));
$en = homeHtml('en');
expect($en)->toContain('daily practice')->toContain('Woven from the fabric of society')->toContain('Article 04');
});
test('home has one h1 and every image has alt text', function () {
foreach (['ar', 'en'] as $locale) {
$html = homeHtml($locale);
expect(substr_count($html, '<h1'))->toBe(1);
preg_match_all('/<img\b[^>]*>/', $html, $m);
foreach ($m[0] as $img) {
expect($img)->toMatch('/\balt="[^"]+"/');
}
}
});
test('site photographs exist as jpegs', function () {
foreach (['olive-tree', 'embroidery-hoop', 'olives', 'hills'] as $name) {
$path = public_path("images/site/$name.jpg");
expect(file_exists($path))->toBeTrue();
expect(bin2hex(substr(file_get_contents($path), 0, 3)))->toBe('ffd8ff');
}
});
test('hero holds the balance and the booking placeholder anchors exist as links', function () {
$html = homeHtml('ar');
expect($html)->toContain('data-balance="hero"')->toContain('class="frame"')->toContain('data-balance="static"');
expect(substr_count($html, 'class="row"'))->toBe(4);
expect(substr_count($html, 'href="#book"'))->toBeGreaterThanOrEqual(5);
expect($html)->toContain(__('home.service_consultation_items', [], 'ar')[0]);
});
test('latest posts render as text-only cards, or the empty message', function () {
$empty = homeHtml('en');
expect($empty)->toContain(__('home.posts_empty', [], 'en'))->not->toContain('class="posts"');
$posts = Post::factory()->published()->count(3)->create();
$html = homeHtml('en');
expect($html)->toContain('class="posts"');
expect(substr_count($html, 'class="post"'))->toBe(3);
expect($html)->toContain(route('posts.show', $posts->first()))->toContain(__('home.read_more', [], 'en'));
preg_match('~<div class="posts">(.*?)</section>~s', $html, $block);
expect($block[1])->not->toContain('<img');
});
test('home has no Unsplash credit', function () {
expect(homeHtml('ar'))->not->toContain('Unsplash');
});
test('site lang keys exist in both languages for the home page', function () {
$keys = ['hero_eyebrow', 'hero_title_a', 'hero_title_em', 'hero_title_b', 'trust_1_title', 'trust_3_text', 'alt_olive_tree', 'balance_label',
'frame_caption', 'frame_tag', 'pillars_label', 'pillar_advocacy_text', 'art_founder', 'founder_role', 'founder_quote', 'founder_cta',
'art_services', 'services_lede', 'art_vision', 'vision_title', 'vision_text', 'alt_hoop', 'card_institution_label', 'card_diaspora_text',
'alt_olives', 'alt_hills', 'art_values', 'art_articles'];
foreach (['ar', 'en'] as $locale) {
$table = require base_path("lang/$locale/site.php");
foreach ($keys as $k) {
expect($table)->toHaveKey($k);
}
}
expect(require base_path('lang/en/site.php'))->toHaveKey('hero_title_a', 'Justice is a ');
});
@@ -0,0 +1,71 @@
<?php
use App\Models\User;
test('public stylesheet exists and every selector is scoped under body.site', function () {
$path = resource_path('css/public.css');
expect(file_exists($path))->toBeTrue();
$lines = preg_grep('/^[^@\/ }].*\{/', file($path, FILE_IGNORE_NEW_LINES));
$unscoped = array_filter($lines, fn ($l) => ! str_contains($l, 'body.site') && ! str_contains($l, 'html:has(body.site)'));
expect($unscoped)->toBe([]);
});
test('public script modules exist', function () {
foreach (['public.js', 'site/reveal.js', 'site/progress.js'] as $f) {
expect(file_exists(resource_path("js/$f")))->toBeTrue();
}
});
test('vite config lists the public entries', function () {
$cfg = file_get_contents(base_path('vite.config.js'));
expect($cfg)->toContain('resources/css/public.css')->toContain('resources/js/public.js');
});
test('home page uses the site foundation', function () {
$html = $this->get('/')->assertOk()->getContent();
expect($html)
->toContain('<body class="site')
->toContain('Markazi+Text')
->toMatch('~build/assets/public-[\w-]+\.css~')
->toMatch('~build/assets/public-[\w-]+\.js~')
->toContain('<div class="site-progress"');
});
test('dashboards do not load the public foundation', function () {
foreach ([[User::factory()->admin()->create(), '/admin/dashboard'], [User::factory()->individual()->create(), '/client/dashboard']] as [$user, $url]) {
$html = $this->actingAs($user)->get($url)->assertOk()->getContent();
expect($html)
->not->toContain('public.css')
->not->toContain('public.js')
->not->toContain('Markazi')
->not->toContain('class="site');
}
});
test('head theme colour is the forest green', function () {
$head = file_get_contents(resource_path('views/partials/head.blade.php'));
expect($head)->toContain('content="#2D3624"')->not->toContain('#4A4A42');
});
test('site lang files carry the progress label', function () {
expect(trans('site.progress_label', [], 'en'))->toBe('Reading progress');
expect(trans('site.progress_label', [], 'ar'))->toBe('تقدّم القراءة');
});
test('body does not become a scroll container, so the sticky navigation sticks', function () {
$css = file_get_contents(resource_path('css/public.css'));
expect($css)->toContain('overflow-x:clip')->toContain('box-sizing:border-box;height:72px');
});
test('reveal uses the translate property so hover lifts (transform) are not overridden', function () {
$css = file_get_contents(resource_path('css/public.css'));
expect($css)->toContain('body.site.js [data-reveal]{opacity:0;translate:0 28px')
->toContain('body.site.js [data-reveal].in{opacity:1;translate:none}')
->not->toContain('[data-reveal].in{opacity:1;transform:none}');
});
@@ -0,0 +1,78 @@
<?php
use App\Models\User;
function siteKeys(): array
{
return ['brand_name', 'brand_sub', 'brand_label', 'nav_main', 'nav_home', 'nav_about', 'nav_services', 'nav_articles',
'nav_booking', 'nav_login', 'nav_book', 'nav_menu', 'lang_switch', 'footer_explore', 'footer_clients',
'footer_contact', 'footer_terms', 'footer_privacy'];
}
test('every nav and footer key exists in both languages', function () {
foreach (['ar', 'en'] as $locale) {
$table = require base_path("lang/$locale/site.php");
foreach (siteKeys() as $key) {
expect($table)->toHaveKey($key);
}
}
});
test('navigation is the sticky site header', function () {
$html = $this->get('/')->assertOk()->getContent();
expect($html)
->toContain('class="nav"')
->toContain('id="site-nav"')
->toContain('class="seal"')
->toContain('class="links"')
->toContain('class="lang"')
->toContain('href="'.route('login').'"')
->toContain('btn btn-gold')
->toContain('class="menu-btn"')
->toContain('mobileMenuOpen');
});
test('about link is current on about, articles on posts', function () {
$about = $this->get('/about')->assertOk()->getContent();
expect($about)->toMatch('~<a[^>]*href="'.preg_quote(route('about'), '~').'"[^>]*aria-current="page"~');
$posts = $this->get('/posts')->assertOk()->getContent();
expect($posts)->toMatch('~<a[^>]*href="'.preg_quote(route('posts.index'), '~').'"[^>]*aria-current="page"~');
});
test('language pill reads EN on Arabic and the Arabic letter on English', function () {
$ar = $this->withSession(['locale' => 'ar'])->get('/')->getContent();
expect($ar)->toContain('href="'.url('/language/en').'"');
$en = $this->withSession(['locale' => 'en'])->get('/')->getContent();
expect($en)->toContain('href="'.url('/language/ar').'"');
});
test('clients and admins see Dashboard and logout instead of login', function () {
$client = $this->actingAs(User::factory()->individual()->create())->get('/')->getContent();
expect($client)->toContain(route('client.dashboard'))->toContain('action="'.route('logout').'"');
auth()->logout();
$admin = $this->actingAs(User::factory()->admin()->create())->get('/')->getContent();
expect($admin)->toContain(route('admin.dashboard'));
});
test('footer has four columns, contact details, legal links and no photo credit', function () {
$html = $this->get('/')->assertOk()->getContent();
expect($html)
->toContain('class="site-footer"')
->toContain('class="foot-band"')
->toContain('tel:+970599353502')
->toContain(route('terms'))
->toContain(route('privacy'))
->toContain('&copy; '.date('Y'))
->not->toContain('Unsplash');
expect(substr_count($html, '<h4'))->toBe(3);
});
test('shared files stay untouched and navigation is only used by the public layout', function () {
$files = shell_exec('grep -rln "x-navigation" '.escapeshellarg(resource_path('views')));
expect(trim($files))->toEndWith('components/layouts/public.blade.php');
});
@@ -0,0 +1,121 @@
<?php
use App\Models\Page;
use App\Models\Post;
use App\Models\User;
use App\Models\WorkingHour;
beforeEach(function () {
foreach (range(0, 6) as $dow) {
WorkingHour::factory()->create(['day_of_week' => $dow, 'is_active' => true, 'start_time' => '09:00', 'end_time' => '13:00']);
}
Post::factory()->published()->count(3)->create();
Page::factory()->terms()->create();
Page::factory()->create(['slug' => 'privacy', 'title_en' => 'Privacy Policy', 'title_ar' => 'سياسة الخصوصية']);
});
function publicUrls(): array
{
return ['/', '/about', '/posts', '/posts/'.Post::first()->id, '/booking', '/booking/success', '/page/terms', '/page/privacy'];
}
test('public pages: one h1, alt text, no raw keys, no mockup text, in both languages', function () {
foreach (['ar', 'en'] as $locale) {
foreach (publicUrls() as $url) {
$html = $this->withSession(['locale' => $locale])->get($url)->assertOk()->getContent();
expect(substr_count($html, '<h1'))->toBe(1, "$locale $url h1");
preg_match_all('/<img\b[^>]*>/', $html, $imgs);
foreach ($imgs[0] as $img) {
expect($img)->toMatch('/\balt="[^"]+"/', "$locale $url img alt");
}
$text = strip_tags(preg_replace('~<(script|style)\b.*?</\1>~s', '', $html));
expect(preg_match('/(?<![\w.@\/-])(site|home|about|booking|posts|footer|pages|common|navigation|accessibility)\.[a-z][a-z0-9_]+(?![\w.-])/', $text, $m))
->toBe(0, "$locale $url raw key: ".($m[0] ?? ''));
expect($html)->not->toContain('Unsplash')->not->toContain('mockup');
expect($html)->toContain('site-progress')->toContain('id="site-nav"');
}
}
});
test('auth pages carry the auth layer in both languages', function () {
foreach (['ar', 'en'] as $locale) {
foreach (['/login', '/forgot-password'] as $url) {
$html = $this->withSession(['locale' => $locale])->get($url)->assertOk()->getContent();
expect($html)->toContain('site-auth')->not->toContain('Unsplash')->not->toContain('mockup');
}
}
});
test('unchanged areas load none of the public assets', function () {
$admin = User::factory()->admin()->create();
foreach (['/admin/dashboard', '/admin/bookings/pending', '/admin/posts'] as $url) {
$html = $this->actingAs($admin)->get($url)->assertOk()->getContent();
expect($html)->not->toContain('public.css')->not->toContain('public.js')->not->toContain('class="site')->not->toContain('site-auth');
}
auth()->logout();
$client = User::factory()->individual()->create();
foreach (['/client/dashboard', '/client/consultations/book'] as $url) {
$html = $this->actingAs($client)->get($url)->assertOk()->getContent();
expect($html)->not->toContain('public.css')->not->toContain('public.js')->not->toContain('class="site')->not->toContain('site-auth');
}
$nablus = $this->get('/forms/nablus-workshop')->assertOk()->getContent();
expect($nablus)->not->toContain('public.css')->not->toContain('class="site');
});
test('the public interactive controls carry no tabindex trap', function () {
$html = $this->get('/')->assertOk()->getContent();
expect($html)->not->toMatch('/tabindex="[1-9]/');
expect($html)->toContain('aria-label="'.__('site.nav_menu').'"');
});
test('measured contrast of the redesign colour pairs meets WCAG AA', function () {
$lum = function (string $hex) {
[$r, $g, $b] = array_map(fn ($i) => hexdec(substr(ltrim($hex, '#'), $i, 2)) / 255, [0, 2, 4]);
$f = fn ($c) => $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
return 0.2126 * $f($r) + 0.7152 * $f($g) + 0.0722 * $f($b);
};
$ratio = function (string $a, string $b) use ($lum) {
[$hi, $lo] = $lum($a) >= $lum($b) ? [$lum($a), $lum($b)] : [$lum($b), $lum($a)];
return ($hi + 0.05) / ($lo + 0.05);
};
expect($ratio('#1E2219', '#F4F1EA'))->toBeGreaterThanOrEqual(4.5); // body text on cream
expect($ratio('#C4A882', '#2D3624'))->toBeGreaterThanOrEqual(4.5); // gold-lt small text on forest
expect($ratio('#C4A882', '#232A1C'))->toBeGreaterThanOrEqual(4.5); // gold-lt on footer
expect($ratio('#1E2219', '#A68966'))->toBeGreaterThanOrEqual(4.5); // text on the gold button
expect($ratio('#7A6648', '#F4F1EA'))->toBeGreaterThanOrEqual(4.5); // gold-ink eyebrow on cream
expect($ratio('#7A6648', '#FFFFFF'))->toBeGreaterThanOrEqual(4.5); // gold-ink post meta on white
expect($ratio('#5B6152', '#F4F1EA'))->toBeGreaterThanOrEqual(4.5); // muted text on cream
});
function nearestLivewireRoot(string $html, string $xpathTarget): ?string
{
$dom = new DOMDocument;
@$dom->loadHTML('<?xml encoding="utf-8"?>'.$html);
$xp = new DOMXPath($dom);
$node = $xp->query($xpathTarget)->item(0);
while ($node instanceof DOMElement) {
if ($node->hasAttribute('wire:id')) {
return $node->getAttribute('wire:snapshot');
}
$node = $node->parentNode;
}
return null;
}
test('the booking form on the home page and /booking sits inside its own Livewire component', function () {
$home = $this->get('/')->assertOk()->getContent();
expect(nearestLivewireRoot($home, '//form[contains(@class,"book-r")]'))->toContain('guestName');
$page = $this->get('/booking')->assertOk()->getContent();
expect(nearestLivewireRoot($page, '//*[contains(@class,"book-r")]'))->toContain('guestName');
});
+40
View File
@@ -0,0 +1,40 @@
<?php
test('hero balance with a label is an img with viewBox and mode', function () {
$html = (string) $this->blade('<x-site.balance mode="hero" label="ميزان العدالة" />');
expect($html)
->toContain('<svg')
->toContain('class="site-balance"')
->toContain('data-balance="hero"')
->toContain('viewBox="0 0 400 400"')
->toContain('role="img"')
->toContain('aria-label="ميزان العدالة"')
->not->toContain('aria-hidden');
});
test('balance without a label is hidden from assistive tech and defaults to static', function () {
$html = (string) $this->blade('<x-site.balance />');
expect($html)->toContain('aria-hidden="true"')->toContain('data-balance="static"')->not->toContain('role="img"');
});
test('size sets width and height and attributes pass through', function () {
$html = (string) $this->blade('<x-site.balance size="120" class="x-extra" id="b1" />');
expect($html)->toContain('width="120"')->toContain('height="120"')->toContain('x-extra')->toContain('id="b1"');
});
test('balance script is wired into public.js and ids are made unique per instance', function () {
$public = file_get_contents(resource_path('js/public.js'));
$balance = file_get_contents(resource_path('js/site/balance.js'));
expect($public)->toContain("./site/balance.js");
expect($balance)->toContain('export function init')->toContain('export function destroy')
->toContain('id="gd${i}"')->toContain('id="sh${i}"')->toContain('++uid')
->toContain('rotate(${deg} 200 110)')->toContain('* 0.028')->toContain('prefers-reduced-motion');
});
test('balance css is scoped', function () {
expect(file_get_contents(resource_path('css/public.css')))->toContain('body.site .site-balance{display:block;overflow:visible}');
});
+4 -11
View File
@@ -170,13 +170,6 @@ describe('Footer Component', function () {
});
describe('Language Toggle in Navigation', function () {
test('language toggle is visible', function () {
$this->get(route('home'))
->assertOk()
->assertSee('data-test="language-switch-ar"', false)
->assertSee('data-test="language-switch-en"', false);
});
test('switching to Arabic applies RTL layout', function () {
$this->get(route('language.switch', 'ar'))
->assertRedirect();
@@ -220,16 +213,16 @@ describe('Footer Translations', function () {
test('English footer translations are loaded', function () {
expect(__('footer.terms', [], 'en'))->toBe('Terms of Service');
expect(__('footer.privacy', [], 'en'))->toBe('Privacy Policy');
expect(__('footer.copyright', [], 'en'))->toBe('Libra Law Firm. All rights reserved.');
expect(__('footer.address', [], 'en'))->toBe('American Building - Next to Al-Shini - 4th Floor');
expect(__('footer.copyright', [], 'en'))->toBe('Libra for Rights. All rights reserved.');
expect(__('footer.address', [], 'en'))->toBe('Jaffa Street - Next to Al-Shini - American Building - 4th Floor');
expect(__('footer.phone', [], 'en'))->toBe('+970 599 353 502');
});
test('Arabic footer translations are loaded', function () {
expect(__('footer.terms', [], 'ar'))->toBe('شروط الخدمة');
expect(__('footer.privacy', [], 'ar'))->toBe('سياسة الخصوصية');
expect(__('footer.copyright', [], 'ar'))->toBe('مكتب ليبرا للمحاماة. جميع الحقوق محفوظة.');
expect(__('footer.address', [], 'ar'))->toBe('العمارة الأمريكية - بجانب الشني - الطابق الرابع');
expect(__('footer.copyright', [], 'ar'))->toBe('مكتب ليبرا للحقوق. جميع الحقوق محفوظة.');
expect(__('footer.address', [], 'ar'))->toBe('شارع يافا - بجانب الشني - العمارة الأمريكية - الطابق الرابع');
expect(__('footer.phone', [], 'ar'))->toBe('+970 599 353 502');
});
});
+3 -306
View File
@@ -19,18 +19,12 @@ test('about page uses public layout', function () {
});
// AC3: Hero Section Container
test('about page hero section has dark forest green background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-[#2D3624]', false);
});
// AC4: Page Title
test('about page displays title in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('About Us');
->assertSee(__('site.nav_about', [], 'en'));
});
test('about page displays title in Arabic', function () {
@@ -56,20 +50,6 @@ test('about page displays tagline in Arabic', function () {
});
// AC6: Identity Statement
test('about page displays identity statement in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('A legal institution woven from the fabric of society, using research as a tool of power to serve justice.');
});
test('about page displays identity statement in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('مؤسسة قانونية منسوجة من نسيج المجتمع، تستخدم البحث كأداة قوة لخدمة العدالة.');
});
// AC8: RTL Support - Verified via locale setting (RTL is handled by layout)
test('about page sets correct language direction for Arabic', function () {
$this->withSession(['locale' => 'ar'])
@@ -90,7 +70,7 @@ test('navigation contains about us link in English', function () {
$this->withSession(['locale' => 'en'])
->get('/')
->assertOk()
->assertSee('About Us')
->assertSee(__('site.nav_about', [], 'en'))
->assertSee('href="'.route('about').'"', false);
});
@@ -117,12 +97,6 @@ test('mobile navigation contains about us link', function () {
// Story 16.2: Vision & Mission Section Tests
// AC1: Vision Section Container
test('about page has vision section with white background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-white py-8', false);
});
// AC2: Vision Section Header
test('about page displays vision title in English', function () {
$this->withSession(['locale' => 'en'])
@@ -139,27 +113,7 @@ test('about page displays vision title in Arabic', function () {
});
// AC3: Vision Statement
test('about page displays vision statement in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Libra is woven from the fabric of society');
});
test('about page displays vision statement in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('ليبرا منسوجة من نسيج المجتمع');
});
// AC4: Mission Section Container
test('about page has mission section with cream background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-background py-8', false);
});
// AC5: Mission Section Header
test('about page displays mission title in English', function () {
$this->withSession(['locale' => 'en'])
@@ -168,13 +122,6 @@ test('about page displays mission title in English', function () {
->assertSee('Our Mission');
});
test('about page displays mission title in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('مهمتنا');
});
// AC6: Mission Cards - Institution
test('about page displays institution mission card in English', function () {
$this->withSession(['locale' => 'en'])
@@ -193,22 +140,6 @@ test('about page displays institution mission card in Arabic', function () {
});
// AC6: Mission Cards - Society
test('about page displays society mission card in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('For Society')
->assertSee('Libra Scholarship for Law and Palestinian Arts');
});
test('about page displays society mission card in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('للمجتمع')
->assertSee('منحة ليبرا للقانون والفنون الفلسطينية');
});
// AC6: Mission Cards - Diaspora
test('about page displays diaspora mission card in English', function () {
$this->withSession(['locale' => 'en'])
@@ -227,19 +158,7 @@ test('about page displays diaspora mission card in Arabic', function () {
});
// AC7: Mission Card Styling
test('about page mission cards have correct styling', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-white rounded-lg shadow-sm p-6', false);
});
// AC8: Responsive Grid
test('about page mission cards use responsive grid', function () {
$this->get('/about')
->assertOk()
->assertSee('grid-cols-1 md:grid-cols-2 lg:grid-cols-3', false);
});
// AC7: Icons present (Flux renders icons as inline SVGs)
test('about page mission cards have icons', function () {
$this->get('/about')
@@ -250,12 +169,6 @@ test('about page mission cards have icons', function () {
// Story 16.3: Values Section Tests
// AC1: Values Section Container
test('about page has values section with white background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-white py-8 sm:py-12 lg:py-[60px]', false);
});
// AC2: Values Section Header
test('about page displays values title in English', function () {
$this->withSession(['locale' => 'en'])
@@ -373,28 +286,10 @@ test('about page displays social innovation value in Arabic', function () {
});
// AC5: Value Card Styling
test('about page value cards have correct styling', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow', false);
});
// AC6: Responsive Grid
test('about page value cards use responsive grid', function () {
$this->get('/about')
->assertOk()
->assertSee('grid-cols-2 md:grid-cols-3 lg:grid-cols-6', false);
});
// Story 16.4: Goals Section Tests
// AC1: Goals Section Container
test('about page has goals section with cream background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-background py-8 sm:py-12 lg:py-[60px]', false);
});
// AC2: Goals Section Header
test('about page displays goals title in English', function () {
$this->withSession(['locale' => 'en'])
@@ -511,26 +406,8 @@ test('about page displays goal 7 in Arabic', function () {
});
// AC4: Goals Card Container Styling
test('about page goals section has white card container', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-white rounded-lg shadow-sm p-6 sm:p-8 max-w-5xl mx-auto', false);
});
// AC5: Numbered Circle Style
test('about page goals have numbered circles with gold background', function () {
$this->get('/about')
->assertOk()
->assertSee('w-8 h-8 bg-accent text-white rounded-full', false);
});
// AC6: Responsive Grid Layout
test('about page goals use responsive grid layout', function () {
$this->get('/about')
->assertOk()
->assertSee('grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-6', false);
});
// AC7: All 7 goal numbers are displayed
test('about page displays all 7 goal numbers', function () {
$response = $this->withSession(['locale' => 'en'])->get('/about');
@@ -543,12 +420,6 @@ test('about page displays all 7 goal numbers', function () {
// Story 16.5: Legal Services Section Tests
// AC1: Services Section Container
test('about page has services section with white background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-white py-8 sm:py-12 lg:py-[60px]', false);
});
// AC2: Services Section Header
test('about page displays services title in English', function () {
$this->withSession(['locale' => 'en'])
@@ -565,20 +436,6 @@ test('about page displays services title in Arabic', function () {
});
// AC3: Services Introduction
test('about page displays services intro in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Libra provides comprehensive legal services as a for-profit law firm, combining traditional legal practice with research-driven insight.');
});
test('about page displays services intro in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('تقدم ليبرا خدمات قانونية شاملة كمكتب محاماة ربحي، يجمع بين الممارسة القانونية التقليدية والرؤية المبنية على البحث.');
});
// AC4: Four Service Categories - Card 1: Legal Consultations & Representation
test('about page displays consultation service card title in English', function () {
$this->withSession(['locale' => 'en'])
@@ -703,42 +560,8 @@ test('about page displays business service card title in Arabic', function () {
->assertSee('الخدمات القانونية للأعمال');
});
test('about page displays business service items in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Contract drafting')
->assertSee('review')
->assertSee('Governance')
->assertSee('compliance')
->assertSee('Arbitration')
->assertSee('Alternative dispute resolution');
});
test('about page displays business service items in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('صياغة ومراجعة العقود')
->assertSee('الحوكمة والامتثال')
->assertSee('التحكيم')
->assertSee('الحل البديل للنزاعات');
});
// AC5: Service Card Styling
test('about page service cards have correct styling', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-background rounded-xl p-8 text-center shadow-sm', false);
});
// AC6: Responsive Grid
test('about page service cards use responsive grid', function () {
$this->get('/about')
->assertOk()
->assertSee('grid-cols-1 md:grid-cols-2 lg:grid-cols-4', false);
});
// AC7: CTA Button
test('about page services section has CTA button in English', function () {
$this->withSession(['locale' => 'en'])
@@ -763,136 +586,10 @@ test('about page services CTA links to booking page', function () {
// Story 16.6: Scholarship Aspiration Section Tests
// AC1: Scholarship Section Container
test('about page has scholarship section with dark forest green background', function () {
$this->get('/about')
->assertOk()
->assertSee('bg-[#2D3624] py-8 sm:py-12 lg:py-[60px]', false);
});
// AC2: Scholarship Section Header
test('about page displays scholarship title in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Our Aspiration: Libra Scholarship');
});
test('about page displays scholarship title in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('طموحنا: منحة ليبرا');
});
// AC3: Scholarship Description
test('about page displays scholarship description paragraph 1 in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Art is the ambassador of Historical Palestine and the Palestinian people.');
});
test('about page displays scholarship description paragraph 1 in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('الفن هو سفير فلسطين التاريخية والشعب الفلسطيني.');
});
test('about page displays scholarship description paragraph 2 in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('We aim to open ongoing discussions about the Palestinian situation and reality');
});
test('about page displays scholarship description paragraph 2 in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('نهدف إلى فتح نقاشات مستمرة حول الوضع والواقع الفلسطيني');
});
// AC4: Key Aspirations List
test('about page displays aspiration 1 in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Empowering students to present Palestinian messages globally');
});
test('about page displays aspiration 1 in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('تمكين الطلاب من تقديم الرسائل الفلسطينية عالمياً');
});
test('about page displays aspiration 2 in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Opening ongoing discussions about Palestinian reality');
});
test('about page displays aspiration 2 in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('فتح نقاشات مستمرة حول الواقع الفلسطيني');
});
test('about page displays aspiration 3 in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Protecting intellectual property rights');
});
test('about page displays aspiration 3 in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('حماية حقوق الملكية الفكرية');
});
test('about page displays aspiration 4 in English', function () {
$this->withSession(['locale' => 'en'])
->get('/about')
->assertOk()
->assertSee('Enabling participation in local and international exhibitions');
});
test('about page displays aspiration 4 in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/about')
->assertOk()
->assertSee('تمكين المشاركة في المعارض المحلية والدولية');
});
// AC5: Aspiration Item Styling (icons in scholarship section)
test('about page scholarship aspirations have icons', function () {
$this->get('/about')
->assertOk()
->assertSee('w-6 h-6 text-accent flex-shrink-0', false);
});
// AC6: Visual Accent (decorative border)
test('about page scholarship section has decorative accent', function () {
$this->get('/about')
->assertOk()
->assertSee('border-accent/20', false);
});
// AC7: Responsive Layout
test('about page scholarship aspirations use responsive grid', function () {
$this->get('/about')
->assertOk()
->assertSee('grid-cols-1 sm:grid-cols-2 lg:grid-cols-4', false);
});
// AC8: All 4 aspirations are present
test('about page displays all 4 scholarship aspirations', function () {
$this->withSession(['locale' => 'en'])->get('/about')->assertOk();
expect(count(__('about.scholarship_aspirations')))->toBe(4);
});
// AC8: All 4 aspirations are present
+1 -1
View File
@@ -33,7 +33,7 @@ test('logged in user sees message to book from dashboard', function () {
test('guest booking page shows calendar', function () {
$this->get(route('booking'))
->assertOk()
->assertSee(__('booking.guest_intro'));
->assertSee('calendar-grid', false);
});
test('guest can select a slot and see contact form', function () {
+15 -93
View File
@@ -83,12 +83,6 @@ test('home page contains services section with id', function () {
// About Section Tests
test('home page contains about section with id', function () {
$this->get('/')
->assertOk()
->assertSee('id="about"', false);
});
test('home page displays about section title in English', function () {
$this->withSession(['locale' => 'en'])
->get('/')
@@ -188,70 +182,6 @@ test('home page displays services section subtitle in Arabic', function () {
->assertSee('حلول قانونية شاملة للأفراد والشركات');
});
test('home page displays legal consultations service in English', function () {
$this->withSession(['locale' => 'en'])
->get('/')
->assertOk()
->assertSee('Legal Consultations')
->assertSee('Expert legal advice tailored to your specific situation and needs.');
});
test('home page displays legal consultations service in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/')
->assertOk()
->assertSee('الاستشارات القانونية')
->assertSee('استشارات قانونية متخصصة مصممة وفقاً لوضعك واحتياجاتك.');
});
test('home page displays court representation service in English', function () {
$this->withSession(['locale' => 'en'])
->get('/')
->assertOk()
->assertSee('Court Representation')
->assertSee('Professional representation before courts and judicial bodies at all stages.');
});
test('home page displays court representation service in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/')
->assertOk()
->assertSee('التمثيل أمام المحاكم')
->assertSee('تمثيل احترافي أمام المحاكم والهيئات القضائية في جميع المراحل.');
});
test('home page displays litigation management service in English', function () {
$this->withSession(['locale' => 'en'])
->get('/')
->assertOk()
->assertSee('Litigation Management')
->assertSee('Complete case management from pleadings and memoranda to appeals.');
});
test('home page displays litigation management service in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/')
->assertOk()
->assertSee('إدارة الدعاوى')
->assertSee('إدارة كاملة للقضايا من اللوائح والمذكرات إلى الاستئنافات.');
});
test('home page displays contract services in English', function () {
$this->withSession(['locale' => 'en'])
->get('/')
->assertOk()
->assertSee('Contract Services')
->assertSee('Drafting, review, and legal compliance for all your contractual needs.');
});
test('home page displays contract services in Arabic', function () {
$this->withSession(['locale' => 'ar'])
->get('/')
->assertOk()
->assertSee('خدمات العقود')
->assertSee('صياغة ومراجعة العقود والامتثال القانوني لجميع احتياجاتكم التعاقدية.');
});
// Values Section Tests
test('home page contains values section with id', function () {
@@ -374,13 +304,6 @@ test('home page displays social innovation value in Arabic', function () {
// Latest Posts Section Tests
test('home page does not display posts section when no published posts exist', function () {
// No posts created
$this->get('/')
->assertOk()
->assertDontSee('id="posts"', false);
});
test('home page displays posts section when published posts exist', function () {
Post::factory()->published()->count(3)->create();
@@ -407,22 +330,6 @@ test('home page displays posts section title in Arabic', function () {
->assertSee('أحدث المقالات');
});
test('home page displays maximum 3 latest posts', function () {
// Create 5 published posts
Post::factory()->published()->count(5)->create();
$response = $this->get('/');
$response->assertOk();
// The section should exist
$response->assertSee('id="posts"', false);
// Count the number of post cards (articles with specific class)
$content = $response->getContent();
$postCardCount = substr_count($content, 'class="bg-card p-6 rounded-lg shadow-card hover:shadow-card-hover transition-shadow"');
expect($postCardCount)->toBe(3);
});
test('home page displays post titles', function () {
$post = Post::factory()->published()->create([
'title' => ['en' => 'Test English Title', 'ar' => 'عنوان اختبار'],
@@ -565,3 +472,18 @@ test('home page displays footer address with street name in Arabic', function ()
->assertOk()
->assertSee('شارع يافا - بجانب الشني - العمارة الأمريكية - الطابق الرابع');
});
test('home page shows the empty articles message when no posts are published', function () {
$this->withSession(['locale' => 'en'])
->get('/')
->assertOk()
->assertSee(__('home.posts_empty', [], 'en'));
});
test('home page displays maximum 3 latest posts', function () {
Post::factory()->published()->count(5)->create();
$html = $this->get('/')->assertOk()->getContent();
expect(substr_count($html, 'class="post"'))->toBe(3);
});
-60
View File
@@ -172,72 +172,12 @@ describe('Forms Responsive', function () {
});
describe('Footer Responsive', function () {
test('footer has responsive grid', function () {
$viewPath = resource_path('views/components/footer.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('grid-cols-1 sm:grid-cols-2 lg:grid-cols-3');
});
test('footer has responsive padding', function () {
$viewPath = resource_path('views/components/footer.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('px-4 sm:px-6 lg:px-8');
});
test('footer links are touch-friendly', function () {
$viewPath = resource_path('views/components/footer.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('min-h-[44px]');
});
});
describe('Public Pages Responsive', function () {
test('home page has responsive grid', function () {
$viewPath = resource_path('views/pages/home.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('grid-cols-1 sm:grid-cols-2 lg:grid-cols-3');
});
test('home page has responsive text sizing', function () {
$viewPath = resource_path('views/pages/home.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('text-2xl sm:text-3xl lg:text-4xl');
});
test('public layout has responsive padding', function () {
$viewPath = resource_path('views/components/layouts/public.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('px-4 sm:px-6 lg:px-8');
});
});
describe('Posts Responsive', function () {
test('posts list has responsive styling', function () {
$viewPath = resource_path('views/livewire/pages/posts/index.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('p-4 sm:p-6');
});
test('posts list uses empty-state class', function () {
$viewPath = resource_path('views/livewire/pages/posts/index.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('empty-state');
});
test('posts read more link is touch-friendly', function () {
$viewPath = resource_path('views/livewire/pages/posts/index.blade.php');
$viewContent = file_get_contents($viewPath);
expect($viewContent)->toContain('min-h-[44px]');
});
});
describe('Consultations Page Responsive', function () {
+1 -1
View File
@@ -7,7 +7,7 @@ import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/css/public.css', 'resources/js/public.js'],
refresh: true,
}),
tailwindcss(),