Compare commits
18 Commits
6ae226f5f2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 88c0a65aa9 | |||
| ad7f531edd | |||
| 379f1f9de6 | |||
| f1ca648672 | |||
| 9263966266 | |||
| 01ff077a5c | |||
| 2c068277a2 | |||
| 7879ac7190 | |||
| 7637294593 | |||
| b1ac864573 | |||
| 10a4e1f4c0 | |||
| d8b27a15e8 | |||
| 8b2c35f76f | |||
| 5f5d1d2b31 | |||
| bcabb2ad2d | |||
| 3d1fe40be6 | |||
| caec396fa4 | |||
| 1d549ac0e6 |
@@ -10,9 +10,31 @@ Libra Law Firm is a bilingual (Arabic/English) web platform for managing client
|
|||||||
|
|
||||||
## Production Environment
|
## Production Environment
|
||||||
|
|
||||||
- **Server:** Rocky Linux 9.6 on 69.62.106.94
|
- **Server:** h1 (`h1.elenoslabs.com`, `163.172.67.77`) — Rocky Linux 9, ElenosLabs-owned. **Not**
|
||||||
- **PHP:** 8.4 (use `php84` command, not `php`)
|
`69.62.106.94` — that stale IP is left over from before the migration to h1 and does not serve
|
||||||
- **Database:** MariaDB/MySQL
|
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
|
### Production Commands
|
||||||
All artisan commands on production require `php84` prefix:
|
All artisan commands on production require `php84` prefix:
|
||||||
@@ -24,16 +46,25 @@ php84 artisan optimize:clear
|
|||||||
|
|
||||||
### Deployment Checklist
|
### Deployment Checklist
|
||||||
```bash
|
```bash
|
||||||
# On production server
|
# On production server (as the libra user, in /home/libra/public_html)
|
||||||
composer install --no-dev --optimize-autoloader
|
git pull
|
||||||
php84 artisan migrate --force
|
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 optimize:clear
|
||||||
php84 artisan config:cache
|
php84 artisan config:cache
|
||||||
php84 artisan route:cache
|
php84 artisan route:cache
|
||||||
php84 artisan view: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
|
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.
|
**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
|
- `app/Enums/` - UserType, UserStatus, ConsultationType, ConsultationStatus, PaymentStatus, TimelineStatus, PostStatus
|
||||||
- `docs/prd.md` - Full product requirements document
|
- `docs/prd.md` - Full product requirements document
|
||||||
- `docs/architecture.md` - Complete architecture 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/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.
|
- `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
|
- **Cascade deletes:** User deletion cascades to consultations, timelines, notifications
|
||||||
- **Bilingual required:** All user-facing text needs Arabic and English
|
- **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)
|
## Design Requirements (from PRD)
|
||||||
|
|
||||||
|
Below: the dashboards' palette and rules. Public pages: Part A of `docs/ux-design.md`.
|
||||||
|
|
||||||
- **Color scheme:**
|
- **Color scheme:**
|
||||||
- Dark Forest Green (#2D3624) - Header/Footer backgrounds
|
- Dark Forest Green (#2D3624) - Header/Footer backgrounds
|
||||||
- Warm Gold (#A68966) - CTA buttons, links, accents
|
- Warm Gold (#A68966) - CTA buttons, links, accents
|
||||||
|
|||||||
+64
-2
@@ -1,7 +1,7 @@
|
|||||||
# Libra Law Firm - Architecture Document
|
# Libra Law Firm - Architecture Document
|
||||||
|
|
||||||
**Version:** 1.1
|
**Version:** 1.2
|
||||||
**Date:** December 21, 2025
|
**Date:** October 31, 2026
|
||||||
**Status:** Approved
|
**Status:** Approved
|
||||||
**Domain:** libra.ps
|
**Domain:** libra.ps
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|------|---------|-------------|--------|
|
|------|---------|-------------|--------|
|
||||||
| Dec 21, 2025 | 1.0 | Initial architecture document | Winston (Architect) |
|
| 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) |
|
| 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
|
└── 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
|
## 7. Authentication & Authorization
|
||||||
|
|||||||
+57
-1
@@ -1,7 +1,7 @@
|
|||||||
# Product Requirements Document (PRD)
|
# Product Requirements Document (PRD)
|
||||||
## Libra Law Firm Website
|
## Libra Law Firm Website
|
||||||
|
|
||||||
**Version:** 1.2
|
**Version:** 1.4
|
||||||
**Date:** January 3, 2026
|
**Date:** January 3, 2026
|
||||||
**Domain:** libra.ps
|
**Domain:** libra.ps
|
||||||
**Prepared for:** Development Team & Client Review
|
**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.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.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.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**
|
**END OF DOCUMENT**
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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.
|
||||||
@@ -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]')`).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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 — 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 — 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-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 |
|
| ID | Title | Status |
|
||||||
|----|-------|--------|
|
|----|-------|--------|
|
||||||
@@ -135,3 +138,14 @@
|
|||||||
| 21 | 21 Nablus Workshop Form - Flyer Size Adjustment | Done |
|
| 21 | 21 Nablus Workshop Form - Flyer Size Adjustment | Done |
|
||||||
| 22 | 22 Fix Footer Address - Add Missing Street Name | Done |
|
| 22 | 22 Fix Footer Address - Add Missing Street Name | Done |
|
||||||
| 23 | 23 Fix AdminNotificationService Carbon TypeError Masking Exceptions | 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
@@ -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
|
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
|
is not a proposal — it describes what shipped, so a dev/QA agent picking up a new task can match
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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' => 'لكلّ حقٍّ وزنُه',
|
||||||
|
];
|
||||||
@@ -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 |
@@ -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}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,68 +1,43 @@
|
|||||||
<footer class="bg-primary mt-auto" data-test="main-footer">
|
<footer class="site-footer" data-test="main-footer">
|
||||||
<div class="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
<span class="foot-band" aria-hidden="true"></span>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
|
<div class="wrap">
|
||||||
<!-- Logo & Description -->
|
<div class="foot">
|
||||||
<div class="text-center sm:text-start">
|
<div>
|
||||||
<x-logo size="lg" class="logo-badge" />
|
<a href="{{ route('home') }}" class="brand" aria-label="{{ __('site.brand_label') }}" wire:navigate>
|
||||||
<p class="mt-3 text-cta text-sm">
|
<span class="seal" style="background-image:url('{{ asset('images/logo.png') }}')"></span>
|
||||||
{{ __('footer.description') }}
|
<span>
|
||||||
</p>
|
<b>{{ __('site.brand_name') }}</b>
|
||||||
|
<small>{{ __('site.brand_sub') }}</small>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p>{{ __('home.tagline') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact Information -->
|
<div>
|
||||||
<div class="text-center sm:text-start">
|
<h4>{{ __('site.footer_explore') }}</h4>
|
||||||
<h3 class="text-off-white font-semibold mb-3">{{ __('footer.contact') }}</h3>
|
<a href="{{ route('about') }}" wire:navigate>{{ __('site.nav_about') }}</a>
|
||||||
<address class="text-cta text-sm not-italic space-y-2">
|
<a href="{{ route('home') }}#services" wire:navigate>{{ __('site.nav_services') }}</a>
|
||||||
<p class="flex items-center justify-center sm:justify-start gap-2">
|
<a href="{{ route('posts.index') }}" wire:navigate>{{ __('site.nav_articles') }}</a>
|
||||||
<flux:icon name="phone" class="w-4 h-4 text-cta flex-shrink-0" />
|
<a href="{{ route('booking') }}" wire:navigate>{{ __('site.nav_booking') }}</a>
|
||||||
<a href="tel:+970599353502" class="hover:text-cta-light transition-colors ltr-inline" data-test="footer-phone">
|
</div>
|
||||||
+970 599 353 502
|
|
||||||
</a>
|
<div>
|
||||||
</p>
|
<h4>{{ __('site.footer_clients') }}</h4>
|
||||||
<p class="flex items-start justify-center sm:justify-start gap-2">
|
<a href="{{ route('login') }}" wire:navigate>{{ __('site.nav_login') }}</a>
|
||||||
<flux:icon name="map-pin" class="w-4 h-4 text-cta flex-shrink-0 mt-0.5" />
|
<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>
|
<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>
|
|
||||||
|
|
||||||
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Copyright -->
|
<div class="legal">
|
||||||
<div class="mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-cta/20 text-center">
|
<span data-test="footer-copyright">© {{ date('Y') }} {{ __('footer.copyright') }}</span>
|
||||||
<p class="text-cta text-xs sm:text-sm" data-test="footer-copyright">
|
|
||||||
© {{ date('Y') }} {{ __('footer.copyright') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
|
@include('partials.site-fonts')
|
||||||
|
@vite(['resources/css/public.css'])
|
||||||
</head>
|
</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 -->
|
<!-- Skip to content link for keyboard accessibility -->
|
||||||
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
||||||
{{ __('accessibility.skip_to_content') }}
|
{{ __('accessibility.skip_to_content') }}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
|
@include('partials.site-fonts')
|
||||||
|
@vite(['resources/css/public.css'])
|
||||||
</head>
|
</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 -->
|
<!-- Skip to content link for keyboard accessibility -->
|
||||||
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
||||||
{{ __('accessibility.skip_to_content') }}
|
{{ __('accessibility.skip_to_content') }}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
|
@include('partials.site-fonts')
|
||||||
|
@vite(['resources/css/public.css'])
|
||||||
</head>
|
</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 -->
|
<!-- Skip to content link for keyboard accessibility -->
|
||||||
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
||||||
{{ __('accessibility.skip_to_content') }}
|
{{ __('accessibility.skip_to_content') }}
|
||||||
|
|||||||
@@ -2,22 +2,20 @@
|
|||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||||
<head>
|
<head>
|
||||||
@include('partials.head')
|
@include('partials.head')
|
||||||
|
@include('partials.site-fonts')
|
||||||
|
@vite(['resources/css/public.css', 'resources/js/public.js'])
|
||||||
</head>
|
</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 -->
|
<!-- Skip to content link for keyboard accessibility -->
|
||||||
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
<a href="#main-content" class="skip-link" data-test="skip-to-content">
|
||||||
{{ __('accessibility.skip_to_content') }}
|
{{ __('accessibility.skip_to_content') }}
|
||||||
</a>
|
</a>
|
||||||
|
<div class="site-progress" aria-hidden="true"></div>
|
||||||
|
|
||||||
<x-navigation />
|
<x-navigation />
|
||||||
|
|
||||||
<!-- Spacer for fixed navigation -->
|
|
||||||
<div class="h-16"></div>
|
|
||||||
|
|
||||||
<main id="main-content" role="main" class="flex-1" tabindex="-1">
|
<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 }}
|
{{ $slot }}
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<x-footer />
|
<x-footer />
|
||||||
|
|||||||
@@ -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 }"
|
x-data="{ mobileMenuOpen: false }"
|
||||||
class="fixed top-0 inset-x-0 z-50 bg-primary"
|
class="nav"
|
||||||
|
id="site-nav"
|
||||||
data-test="main-navigation"
|
data-test="main-navigation"
|
||||||
>
|
>
|
||||||
<div class="max-w-[1200px] mx-auto px-4">
|
<div class="wrap">
|
||||||
<div class="flex items-center justify-between h-16">
|
<a href="{{ route('home') }}" class="brand" aria-label="{{ __('site.brand_label') }}" wire:navigate>
|
||||||
<!-- Logo - Left on desktop -->
|
<span class="seal" style="background-image:url('{{ asset('images/logo.png') }}')"></span>
|
||||||
<div class="shrink-0 hidden md:block">
|
<span>
|
||||||
<a href="{{ route('home') }}" class="flex items-center" wire:navigate>
|
<b>{{ __('site.brand_name') }}</b>
|
||||||
<x-logo size="md" class="logo-badge" />
|
<small>{{ __('site.brand_sub') }}</small>
|
||||||
</a>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
</a>
|
||||||
|
|
||||||
<!-- Language Toggle on Mobile -->
|
<nav class="links" aria-label="{{ __('site.nav_main') }}">
|
||||||
<div class="min-w-[44px]">
|
@foreach ($links as $link)
|
||||||
<x-language-toggle />
|
<a
|
||||||
</div>
|
href="{{ $link['href'] }}"
|
||||||
</div>
|
@if ($link['current']) aria-current="page" @endif
|
||||||
|
wire:navigate
|
||||||
|
data-test="nav-{{ $link['key'] }}"
|
||||||
|
>{{ $link['label'] }}</a>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
|
||||||
<!-- Desktop Navigation Links -->
|
<div class="nav-tools">
|
||||||
<div class="hidden md:flex md:items-center md:gap-6">
|
<a class="lang" href="{{ $langHref }}" aria-label="{{ __('site.lang_switch') }}" data-test="nav-language">{{ $langText }}</a>
|
||||||
<a
|
|
||||||
href="{{ route('home') }}"
|
|
||||||
@class([
|
|
||||||
'text-off-white hover:text-cta transition-colors py-2',
|
|
||||||
'border-b-2 border-cta' => request()->routeIs('home'),
|
|
||||||
])
|
|
||||||
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>
|
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
@php
|
@php
|
||||||
$dashboardRoute = auth()->user()->isAdmin() ? route('admin.dashboard') : route('client.dashboard');
|
$dashboardRoute = auth()->user()->isAdmin() ? route('admin.dashboard') : route('client.dashboard');
|
||||||
@endphp
|
@endphp
|
||||||
<a
|
<a class="login" href="{{ $dashboardRoute }}" wire:navigate data-test="nav-dashboard">{{ __('navigation.dashboard') }}</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">
|
<form method="POST" action="{{ route('logout') }}" class="inline">
|
||||||
@csrf
|
@csrf
|
||||||
<button
|
<button type="submit" class="login" data-test="nav-logout">{{ __('navigation.logout') }}</button>
|
||||||
type="submit"
|
|
||||||
class="text-off-white hover:text-cta transition-colors py-2"
|
|
||||||
data-test="nav-logout"
|
|
||||||
>
|
|
||||||
{{ __('navigation.logout') }}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
@else
|
@else
|
||||||
<a
|
<a class="login" href="{{ route('login') }}" wire:navigate data-test="nav-login">{{ __('site.nav_login') }}</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
|
@endauth
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop Language Toggle -->
|
<a class="btn btn-gold" href="{{ route('home') }}#book">{{ __('site.nav_book') }}</a>
|
||||||
<div class="hidden md:block">
|
|
||||||
<x-language-toggle />
|
<button
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<!-- Mobile menu sheet -->
|
||||||
<div
|
<div
|
||||||
x-show="mobileMenuOpen"
|
x-show="mobileMenuOpen"
|
||||||
x-trap.inert.noscroll="mobileMenuOpen"
|
x-trap.inert.noscroll="mobileMenuOpen"
|
||||||
@@ -148,102 +85,32 @@
|
|||||||
x-cloak
|
x-cloak
|
||||||
@click.away="mobileMenuOpen = false"
|
@click.away="mobileMenuOpen = false"
|
||||||
@keydown.escape.window="mobileMenuOpen = false"
|
@keydown.escape.window="mobileMenuOpen = false"
|
||||||
class="md:hidden bg-primary border-t border-cta/20"
|
class="menu-sheet"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label="{{ __('Mobile navigation menu') }}"
|
aria-label="{{ __('site.nav_menu') }}"
|
||||||
data-test="mobile-menu"
|
data-test="mobile-menu"
|
||||||
>
|
>
|
||||||
<div class="px-4 py-3 space-y-1">
|
@foreach ($links as $link)
|
||||||
<a
|
<a
|
||||||
href="{{ route('home') }}"
|
href="{{ $link['href'] }}"
|
||||||
@class([
|
@if ($link['current']) aria-current="page" @endif
|
||||||
'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'),
|
|
||||||
])
|
|
||||||
wire:navigate
|
wire:navigate
|
||||||
@click="mobileMenuOpen = false"
|
@click="mobileMenuOpen = false"
|
||||||
data-test="mobile-nav-home"
|
data-test="mobile-nav-{{ $link['key'] }}"
|
||||||
>
|
>{{ $link['label'] }}</a>
|
||||||
{{ __('navigation.home') }}
|
@endforeach
|
||||||
</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>
|
|
||||||
|
|
||||||
@auth
|
@auth
|
||||||
@php
|
<a href="{{ $dashboardRoute }}" wire:navigate @click="mobileMenuOpen = false" data-test="mobile-nav-dashboard">{{ __('navigation.dashboard') }}</a>
|
||||||
$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') }}">
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button
|
<button type="submit" data-test="mobile-nav-logout">{{ __('navigation.logout') }}</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>
|
</form>
|
||||||
@else
|
@else
|
||||||
<a
|
<a href="{{ route('login') }}" wire:navigate @click="mobileMenuOpen = false" data-test="mobile-nav-login">{{ __('site.nav_login') }}</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
|
@endauth
|
||||||
|
|
||||||
|
<a class="btn btn-gold" href="{{ route('home') }}#book" @click="mobileMenuOpen = false">{{ __('site.nav_book') }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</nav>
|
|
||||||
|
|||||||
@@ -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 App\Services\AvailabilityService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Livewire\Attributes\Reactive;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component
|
new class extends Component
|
||||||
@@ -18,12 +19,54 @@ new class extends Component
|
|||||||
|
|
||||||
public array $bookedDates = [];
|
public array $bookedDates = [];
|
||||||
|
|
||||||
|
public string $variant = 'month';
|
||||||
|
|
||||||
|
#[Reactive]
|
||||||
|
public ?string $pickedDate = null;
|
||||||
|
|
||||||
|
#[Reactive]
|
||||||
|
public ?string $pickedTime = null;
|
||||||
|
|
||||||
public function mount(array $bookedDates = []): void
|
public function mount(array $bookedDates = []): void
|
||||||
{
|
{
|
||||||
$this->bookedDates = $bookedDates;
|
$this->bookedDates = $bookedDates;
|
||||||
$this->year = now()->year;
|
$this->year = now()->year;
|
||||||
$this->month = now()->month;
|
$this->month = now()->month;
|
||||||
$this->loadMonthAvailability();
|
$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
|
public function loadMonthAvailability(): void
|
||||||
@@ -65,7 +108,9 @@ new class extends Component
|
|||||||
return;
|
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'])) {
|
if (in_array($status, ['available', 'partial'])) {
|
||||||
$this->selectedDate = $date;
|
$this->selectedDate = $date;
|
||||||
@@ -151,20 +196,56 @@ new class extends Component
|
|||||||
'calendarDays' => $this->buildCalendarDays(),
|
'calendarDays' => $this->buildCalendarDays(),
|
||||||
'dayHeaders' => $this->getDayHeaders(),
|
'dayHeaders' => $this->getDayHeaders(),
|
||||||
'bookedDates' => $this->bookedDates,
|
'bookedDates' => $this->bookedDates,
|
||||||
|
'stripDays' => $this->variant === 'strip' ? $this->buildStripDays() : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div>
|
<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 -->
|
<!-- Calendar Header -->
|
||||||
<div class="flex items-center justify-between mb-4">
|
<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:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'right' : 'left' }}" class="size-5" />
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|
||||||
<flux:heading size="lg" class="text-body">{{ $monthName }}</flux:heading>
|
<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:icon name="chevron-{{ app()->getLocale() === 'ar' ? 'left' : 'right' }}" class="size-5" />
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,7 +268,10 @@ new class extends Component
|
|||||||
<button
|
<button
|
||||||
wire:click="selectDate('{{ $dayData['date'] }}')"
|
wire:click="selectDate('{{ $dayData['date'] }}')"
|
||||||
@class([
|
@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-[#4A5D23]/70 hover:bg-[#4A5D23]/85' => $dayData['status'] === 'available',
|
||||||
'bg-[#D4A84B]/70 hover:bg-[#D4A84B]/85' => $dayData['status'] === 'partial',
|
'bg-[#D4A84B]/70 hover:bg-[#D4A84B]/85' => $dayData['status'] === 'partial',
|
||||||
'bg-sky-400/50 cursor-not-allowed' => $dayData['status'] === 'user_booked',
|
'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>
|
<span class="text-zinc-600">{{ __('booking.unavailable') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,286 +9,111 @@ new #[Layout('components.layouts.public')] class extends Component
|
|||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{{-- Hero Section --}}
|
{{-- Hero --}}
|
||||||
<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">
|
<section class="sec" style="padding-bottom:64px">
|
||||||
<div class="container mx-auto px-4 text-center">
|
<div class="wrap" style="max-width:52em">
|
||||||
{{-- Page Title --}}
|
<span class="eyebrow" data-reveal>{{ __('about.tagline') }}</span>
|
||||||
<h1 class="text-[2rem] sm:text-[2.5rem] lg:text-[3rem] font-bold text-text mb-2">
|
<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>
|
||||||
{{ __('about.title') }}
|
<p class="lede" data-reveal data-d="2">{{ __('about.identity') }}</p>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- Vision Section --}}
|
{{-- Vision and mission --}}
|
||||||
<section class="bg-white py-8 sm:py-12 lg:py-[60px]">
|
<section class="sec fabric">
|
||||||
<div class="container mx-auto px-4">
|
<div class="wrap">
|
||||||
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
|
<div class="fabric-grid">
|
||||||
{{ __('about.vision_title') }}
|
<div data-reveal>
|
||||||
</h2>
|
<span class="art-no">{{ __('site.about_art_vision') }}</span>
|
||||||
<div class="w-20 h-1 bg-accent mx-auto mb-8"></div>
|
<h2>{{ __('about.vision_title') }}</h2>
|
||||||
<blockquote class="max-w-[900px] mx-auto text-center text-lg text-text/90">
|
<p class="big">{{ __('about.vision_text') }}</p>
|
||||||
<span class="text-4xl text-accent leading-none align-top">"</span>
|
<h3 style="margin-top:28px">{{ __('about.mission_title') }}</h3>
|
||||||
<span class="italic">{{ __('about.vision_text') }}</span>
|
<p>{{ __('about.mission_intro') }}</p>
|
||||||
<span class="text-4xl text-accent leading-none align-bottom">"</span>
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<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>
|
||||||
|
|
||||||
{{-- 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>
|
|
||||||
</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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- Values Section --}}
|
{{-- Values --}}
|
||||||
<section class="bg-white py-8 sm:py-12 lg:py-[60px]">
|
<section class="sec">
|
||||||
<div class="container mx-auto px-4">
|
<div class="wrap">
|
||||||
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
|
<x-site.section-head :label="__('site.about_art_values')" :title="__('about.values_title')" :lede="__('about.values_intro')" />
|
||||||
{{ __('about.values_title') }}
|
@php
|
||||||
</h2>
|
$valueIcons = [
|
||||||
<div class="w-20 h-1 bg-accent mx-auto mb-4"></div>
|
'<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"/>',
|
||||||
<p class="text-center text-text/70 max-w-xl mx-auto mb-12">
|
'<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"/>',
|
||||||
{{ __('about.values_intro') }}
|
'<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"/>',
|
||||||
</p>
|
'<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"/>',
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 lg:gap-6">
|
'<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"/>',
|
||||||
{{-- Integrity --}}
|
];
|
||||||
<div class="bg-background rounded-xl p-6 text-center hover:shadow-md transition-shadow">
|
@endphp
|
||||||
<div class="text-accent mb-3">
|
<div class="sampler">
|
||||||
<flux:icon name="shield-check" class="w-10 h-10 mx-auto" />
|
@foreach (['integrity', 'justice', 'knowledge', 'women', 'professionalism', 'innovation'] as $i => $value)
|
||||||
</div>
|
<div class="val" data-reveal @if ($i) data-d="{{ $i }}" @endif>
|
||||||
<h4 class="font-semibold text-text">
|
<span class="medal"><svg viewBox="0 0 24 24" aria-hidden="true">{!! $valueIcons[$i] !!}</svg></span>
|
||||||
{{ __('about.value_integrity') }}
|
<b>{{ __('about.value_'.$value) }}</b>
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- Services Section --}}
|
{{-- Goals --}}
|
||||||
<section class="bg-white py-8 sm:py-12 lg:py-[60px]">
|
<section class="sec" style="padding-top:20px">
|
||||||
<div class="container mx-auto px-4">
|
<div class="wrap">
|
||||||
<h2 class="text-2xl lg:text-3xl font-bold text-text text-center mb-2">
|
<x-site.section-head :label="__('site.about_art_goals')" :title="__('about.goals_title')" />
|
||||||
{{ __('about.services_title') }}
|
<ol class="docket" style="list-style:none;margin:0;padding:0">
|
||||||
</h2>
|
@foreach (__('about.goals') as $i => $goal)
|
||||||
<div class="w-20 h-1 bg-accent mx-auto mb-4"></div>
|
<li class="row row-static" data-reveal>
|
||||||
<p class="text-center text-text/70 max-w-3xl mx-auto mb-12">
|
<span class="n">{{ sprintf('%02d', $i + 1) }}</span>
|
||||||
{{ __('about.services_intro') }}
|
<p style="font-size:1.1rem;color:var(--ink);margin:0">{{ $goal }}</p>
|
||||||
</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>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{{-- Women's & Family Law --}}
|
{{-- Legal services --}}
|
||||||
<div class="bg-background rounded-xl p-8 text-center shadow-sm">
|
<section class="sec" id="services" style="padding-top:20px">
|
||||||
<div class="text-accent mb-4">
|
<div class="wrap">
|
||||||
<flux:icon name="heart" class="w-12 h-12 mx-auto" />
|
<x-site.section-head :label="__('site.about_art_services')" :title="__('about.services_title')" :lede="__('about.services_intro')" />
|
||||||
</div>
|
<div class="docket">
|
||||||
<h3 class="text-lg font-bold text-text mb-4">
|
@foreach (['consultation', 'family', 'civil', 'business'] as $i => $service)
|
||||||
{{ __('about.service_family_title') }}
|
<a class="row" href="{{ route('booking') }}" wire:navigate data-reveal @if ($i) data-d="{{ $i }}" @endif>
|
||||||
</h3>
|
<span class="n">{{ sprintf('%02d', $i + 1) }}</span>
|
||||||
<ul class="text-sm text-text/80 space-y-2 text-start">
|
<h3>{{ __('about.service_'.$service.'_title') }}</h3>
|
||||||
@foreach(__('about.service_family_items') as $item)
|
<div class="chips">
|
||||||
<li class="flex items-center gap-2">
|
@foreach (__('about.service_'.$service.'_items') as $item)
|
||||||
<span class="w-1.5 h-1.5 bg-accent rounded-full flex-shrink-0"></span>
|
<span class="chip">{{ $item }}</span>
|
||||||
{{ $item }}
|
|
||||||
</li>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
|
||||||
</div>
|
</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>
|
||||||
{{-- Civil & Commercial Disputes --}}
|
</a>
|
||||||
<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
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{{-- 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>
|
</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">
|
<section class="sec">
|
||||||
<flux:icon name="check-circle" class="w-16 h-16 mx-auto text-green-500 mb-6" />
|
<div class="wrap">
|
||||||
|
<div class="success-card">
|
||||||
<flux:heading size="xl" class="mb-4 text-body">
|
<x-site.balance mode="static" style="width:120px;height:120px;margin:0 auto 18px" />
|
||||||
{{ __('booking.success_title') }}
|
<h1>{{ __('booking.success_title') }}</h1>
|
||||||
</flux:heading>
|
<p>{{ __('booking.success_message') }}</p>
|
||||||
|
<a class="btn btn-ghost" href="{{ route('home') }}" wire:navigate>{{ __('site.nav_home') }}</a>
|
||||||
<p class="text-zinc-600 mb-6">
|
</div>
|
||||||
{{ __('booking.success_message') }}
|
</div>
|
||||||
</p>
|
</section>
|
||||||
|
|
||||||
<flux:button href="{{ route('home') }}">
|
|
||||||
{{ __('navigation.home') }}
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -37,8 +37,12 @@ new #[Layout('components.layouts.public')] class extends Component
|
|||||||
|
|
||||||
public bool $isLoggedIn = false;
|
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
|
// Check if user is logged in - show friendly message instead of booking form
|
||||||
if (auth()->check()) {
|
if (auth()->check()) {
|
||||||
$this->isLoggedIn = true;
|
$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">
|
<div>
|
||||||
<flux:heading size="xl" class="mb-6 text-body">
|
@if($compact)
|
||||||
{{ __('booking.request_consultation') }}
|
|
||||||
</flux:heading>
|
|
||||||
|
|
||||||
@if($isLoggedIn)
|
@if($isLoggedIn)
|
||||||
{{-- Logged-in user message --}}
|
<div class="book-r book-r-note">
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-amber-200 p-8 text-center">
|
<h3>{{ __('booking.logged_in_title') }}</h3>
|
||||||
<div class="mb-6">
|
<p>{{ __('booking.logged_in_message') }}</p>
|
||||||
<div class="mx-auto w-16 h-16 bg-amber-100 rounded-full flex items-center justify-center mb-4">
|
<a class="btn btn-gold" href="{{ route('client.consultations.book') }}">{{ __('booking.go_to_dashboard') }}</a>
|
||||||
<flux:icon name="user-circle" class="w-8 h-8 text-amber-600" />
|
</div>
|
||||||
|
@elseif($showConfirmation)
|
||||||
|
<div class="book-r" data-test="compact-confirmation">
|
||||||
|
<h3>{{ __('booking.confirm_booking') }}</h3>
|
||||||
|
<p class="note" style="text-align:start">{{ __('booking.confirm_message') }}</p>
|
||||||
|
<dl class="summary">
|
||||||
|
<dt>{{ __('booking.guest_name') }}</dt><dd>{{ $guestName }}</dd>
|
||||||
|
<dt>{{ __('booking.guest_email') }}</dt><dd dir="ltr">{{ $guestEmail }}</dd>
|
||||||
|
<dt>{{ __('booking.guest_phone') }}</dt><dd dir="ltr">{{ $guestPhone }}</dd>
|
||||||
|
<dt>{{ __('booking.date') }}</dt><dd>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</dd>
|
||||||
|
<dt>{{ __('booking.time') }}</dt><dd dir="ltr">{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</dd>
|
||||||
|
<dt>{{ __('booking.problem_summary') }}</dt><dd>{{ $problemSummary }}</dd>
|
||||||
|
</dl>
|
||||||
|
@error('selectedTime')
|
||||||
|
<p class="err" role="alert">{{ $message }}</p>
|
||||||
|
@enderror
|
||||||
|
<button type="button" class="btn btn-gold" wire:click="submit" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
|
||||||
|
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" wire:click="$set('showConfirmation', false)">{{ __('common.back') }}</button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<form class="book-r" wire:submit="showConfirm" novalidate>
|
||||||
|
@if(session('success'))
|
||||||
|
<p class="ok" role="status">{{ session('success') }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="f2">
|
||||||
|
<div class="fld">
|
||||||
|
<label for="bk-name">{{ __('booking.guest_name') }} *</label>
|
||||||
|
<input id="bk-name" type="text" wire:model="guestName" autocomplete="name">
|
||||||
|
@error('guestName') <p class="err">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
<div class="fld">
|
||||||
|
<label for="bk-phone">{{ __('booking.guest_phone') }} *</label>
|
||||||
|
<input id="bk-phone" type="tel" dir="ltr" wire:model="guestPhone" autocomplete="tel">
|
||||||
|
@error('guestPhone') <p class="err">{{ $message }}</p> @enderror
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row gap-3 justify-center">
|
<div class="fld">
|
||||||
<flux:button
|
<label for="bk-email">{{ __('booking.guest_email') }} *</label>
|
||||||
href="{{ route('client.consultations.book') }}"
|
<input id="bk-email" type="email" dir="ltr" wire:model="guestEmail" autocomplete="email">
|
||||||
variant="primary"
|
@error('guestEmail') <p class="err">{{ $message }}</p> @enderror
|
||||||
class="min-h-[44px]"
|
|
||||||
>
|
|
||||||
<flux:icon name="calendar" class="w-4 h-4 me-2" />
|
|
||||||
{{ __('booking.go_to_dashboard') }}
|
|
||||||
</flux:button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-zinc-500">
|
<div class="fld">
|
||||||
{{ __('booking.or_logout') }}
|
<livewire:availability-calendar variant="strip" :picked-date="$selectedDate" :picked-time="$selectedTime" />
|
||||||
</p>
|
@error('selectedTime') <p class="err">{{ $message }}</p> @enderror
|
||||||
|
@error('selectedDate') <p class="err">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fld">
|
||||||
|
<label for="bk-summary">{{ __('booking.problem_summary') }} *</label>
|
||||||
|
<textarea id="bk-summary" rows="3" wire:model="problemSummary"></textarea>
|
||||||
|
<p class="help">{{ __('booking.problem_summary_help') }}</p>
|
||||||
|
@error('problemSummary') <p class="err">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fld cap">
|
||||||
|
<label for="bk-captcha">{{ app()->getLocale() === 'ar' ? $captchaQuestion['question_ar'] : $captchaQuestion['question'] }} *</label>
|
||||||
|
<input id="bk-captcha" inputmode="numeric" dir="ltr" wire:model="captchaAnswer" style="max-width:140px">
|
||||||
|
@error('captchaAnswer') <p class="err">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-gold" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.remove wire:target="showConfirm">{{ __('site.booking_submit') }}</span>
|
||||||
|
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
|
||||||
|
</button>
|
||||||
|
<p class="note">{{ __('booking.limit_message') }}</p>
|
||||||
|
</form>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<section class="sec" style="padding-top:56px">
|
||||||
|
<div class="wrap">
|
||||||
|
<x-site.section-head tag="h1" :label="__('site.art_booking')" :title="__('booking.request_consultation')" :lede="__('site.booking_lede')" />
|
||||||
|
|
||||||
|
<div class="book">
|
||||||
|
<div class="book-l on-dark">
|
||||||
|
<div class="contact">
|
||||||
|
<a href="tel:+970599353502"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/></svg><span dir="ltr">{{ __('footer.phone') }}</span></a>
|
||||||
|
<a href="mailto:{{ __('footer.email') }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg><span>{{ __('footer.email') }}</span></a>
|
||||||
|
<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 21s7-6.2 7-12a7 7 0 0 0-14 0c0 5.8 7 12 7 12z"/><circle cx="12" cy="9" r="2.5"/></svg><span>{{ __('footer.address') }}</span></span>
|
||||||
|
</div>
|
||||||
|
<x-site.balance mode="static" style="width:190px;height:190px;margin-top:22px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="book-r">
|
||||||
|
@if($isLoggedIn)
|
||||||
|
<div class="book-r-note">
|
||||||
|
<h3>{{ __('booking.logged_in_title') }}</h3>
|
||||||
|
<p>{{ __('booking.logged_in_message') }}</p>
|
||||||
|
<a class="btn btn-gold" href="{{ route('client.consultations.book') }}">{{ __('booking.go_to_dashboard') }}</a>
|
||||||
|
<p class="note">{{ __('booking.or_logout') }}</p>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@if(session('success'))
|
@if(session('success'))
|
||||||
<flux:callout variant="success" class="mb-6">
|
<p class="ok" role="status">{{ session('success') }}</p>
|
||||||
{{ session('success') }}
|
|
||||||
</flux:callout>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(!$selectedDate || !$selectedTime)
|
@if(!$selectedDate || !$selectedTime)
|
||||||
{{-- Step 1: Calendar Selection --}}
|
<p class="note" style="text-align:start;margin:0 0 14px">{{ __('booking.select_date_time') }}</p>
|
||||||
{{-- Booking Expectations --}}
|
|
||||||
<x-booking-expectations class="mb-6" />
|
|
||||||
|
|
||||||
<p class="mb-4 text-zinc-600">
|
|
||||||
{{ __('booking.select_date_time') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<livewire:availability-calendar />
|
<livewire:availability-calendar />
|
||||||
@else
|
@else
|
||||||
{{-- Step 2+: Contact Form & Confirmation --}}
|
<div class="picked">
|
||||||
<div class="bg-amber-50 p-4 rounded-lg mb-6 border border-amber-200">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-zinc-900">
|
<b>{{ __('booking.selected_time') }}</b>
|
||||||
{{ __('booking.selected_time') }}
|
<span>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</span>
|
||||||
</p>
|
<span dir="ltr">{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</span>
|
||||||
<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>
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost" wire:click="clearSelection">{{ __('common.change') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(!$showConfirmation)
|
@if(!$showConfirmation)
|
||||||
{{-- Contact Form --}}
|
<div class="fld">
|
||||||
<div class="space-y-4">
|
<label for="bp-name">{{ __('booking.guest_name') }} *</label>
|
||||||
<flux:field>
|
<input id="bp-name" type="text" wire:model="guestName" autocomplete="name">
|
||||||
<flux:label class="required">{{ __('booking.guest_name') }}</flux:label>
|
@error('guestName') <p class="err">{{ $message }}</p> @enderror
|
||||||
<flux:input wire:model="guestName" type="text" />
|
|
||||||
<flux:error name="guestName" />
|
|
||||||
</flux:field>
|
|
||||||
|
|
||||||
<flux:field>
|
|
||||||
<flux:label class="required">{{ __('booking.guest_email') }}</flux:label>
|
|
||||||
<flux:input wire:model="guestEmail" type="email" />
|
|
||||||
<flux:error name="guestEmail" />
|
|
||||||
</flux:field>
|
|
||||||
|
|
||||||
<flux:field>
|
|
||||||
<flux:label class="required">{{ __('booking.guest_phone') }}</flux:label>
|
|
||||||
<flux:input wire:model="guestPhone" type="tel" />
|
|
||||||
<flux:error name="guestPhone" />
|
|
||||||
</flux:field>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{{-- 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>
|
|
||||||
</div>
|
</div>
|
||||||
<flux:error name="captchaAnswer" />
|
<div class="fld">
|
||||||
</flux:field>
|
<label for="bp-email">{{ __('booking.guest_email') }} *</label>
|
||||||
|
<input id="bp-email" type="email" dir="ltr" wire:model="guestEmail" autocomplete="email">
|
||||||
<flux:button
|
@error('guestEmail') <p class="err">{{ $message }}</p> @enderror
|
||||||
wire:click="showConfirm"
|
</div>
|
||||||
class="w-full sm:w-auto min-h-[44px]"
|
<div class="fld">
|
||||||
wire:loading.attr="disabled"
|
<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') }}">↻</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.remove wire:target="showConfirm">{{ __('booking.continue') }}</span>
|
||||||
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
|
<span wire:loading wire:target="showConfirm">{{ __('common.loading') }}</span>
|
||||||
</flux:button>
|
</button>
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
{{-- Confirmation Step --}}
|
<h3>{{ __('booking.confirm_booking') }}</h3>
|
||||||
<flux:callout>
|
<p class="note" style="text-align:start">{{ __('booking.confirm_message') }}</p>
|
||||||
<flux:heading size="sm" class="text-body">{{ __('booking.confirm_booking') }}</flux:heading>
|
<dl class="summary">
|
||||||
<p class="text-zinc-600">{{ __('booking.confirm_message') }}</p>
|
<dt>{{ __('booking.guest_name') }}</dt><dd>{{ $guestName }}</dd>
|
||||||
|
<dt>{{ __('booking.guest_email') }}</dt><dd dir="ltr">{{ $guestEmail }}</dd>
|
||||||
<div class="mt-4 space-y-2">
|
<dt>{{ __('booking.guest_phone') }}</dt><dd dir="ltr">{{ $guestPhone }}</dd>
|
||||||
<p><strong>{{ __('booking.guest_name') }}:</strong> {{ $guestName }}</p>
|
<dt>{{ __('booking.date') }}</dt><dd>{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</dd>
|
||||||
<p><strong>{{ __('booking.guest_email') }}:</strong> {{ $guestEmail }}</p>
|
<dt>{{ __('booking.time') }}</dt><dd dir="ltr">{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</dd>
|
||||||
<p><strong>{{ __('booking.guest_phone') }}:</strong> {{ $guestPhone }}</p>
|
<dt>{{ __('booking.duration') }}</dt><dd>45 {{ __('common.minutes') }}</dd>
|
||||||
<p><strong>{{ __('booking.date') }}:</strong>
|
<dt>{{ __('booking.problem_summary') }}</dt><dd>{{ $problemSummary }}</dd>
|
||||||
{{ \Carbon\Carbon::parse($selectedDate)->translatedFormat('l, d M Y') }}</p>
|
</dl>
|
||||||
<p><strong>{{ __('booking.time') }}:</strong>
|
@error('selectedTime')
|
||||||
{{ \Carbon\Carbon::parse($selectedTime)->format('g:i A') }}</p>
|
<p class="err" role="alert">{{ $message }}</p>
|
||||||
<p><strong>{{ __('booking.duration') }}:</strong> 45 {{ __('common.minutes') }}</p>
|
@enderror
|
||||||
</div>
|
<button type="button" class="btn btn-gold" wire:click="submit" wire:loading.attr="disabled">
|
||||||
|
|
||||||
<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.remove wire:target="submit">{{ __('booking.submit_request') }}</span>
|
||||||
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
|
<span wire:loading wire:target="submit">{{ __('common.submitting') }}</span>
|
||||||
</flux:button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-ghost" wire:click="$set('showConfirmation', false)">{{ __('common.back') }}</button>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@error('selectedTime')
|
<x-booking-expectations class="mt-10" />
|
||||||
<flux:callout variant="danger" class="mt-4">
|
</div>
|
||||||
{{ $message }}
|
</section>
|
||||||
</flux:callout>
|
@endif
|
||||||
@enderror
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,220 +18,174 @@ new #[Layout('components.layouts.public')] class extends Component
|
|||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{{-- Hero Section --}}
|
{{-- Hero --}}
|
||||||
<section class="bg-background py-8 sm:py-12 lg:py-16">
|
<section class="hero" id="top">
|
||||||
<div class="container mx-auto px-4 text-center">
|
<div class="wrap">
|
||||||
{{-- Tagline --}}
|
<div>
|
||||||
<h1 class="text-[1.75rem] sm:text-[2rem] lg:text-[2.5rem] font-bold text-text mb-2">
|
<span class="eyebrow" data-reveal>{{ __('site.hero_eyebrow') }}</span>
|
||||||
{{ __('home.tagline') }}
|
<h1 data-reveal data-d="1">{{ __('site.hero_title_a') }}<em>{{ __('site.hero_title_em') }}</em>{{ __('site.hero_title_b') }}</h1>
|
||||||
</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>
|
||||||
|
|
||||||
{{-- Introductory Text --}}
|
{{-- Pillars band --}}
|
||||||
<p class="text-text text-base sm:text-lg max-w-[800px] mx-auto mb-6">
|
<section class="band on-dark" aria-label="{{ __('site.pillars_label') }}">
|
||||||
{{ __('home.intro') }}
|
<div class="wrap pillars">
|
||||||
</p>
|
@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>
|
||||||
|
|
||||||
{{-- CTA Buttons --}}
|
{{-- Founder --}}
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<section class="sec" id="founder">
|
||||||
<a href="{{ route('booking') }}" class="btn-primary">
|
<div class="wrap founder">
|
||||||
{{ __('home.cta_book') }}
|
<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 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>
|
||||||
|
<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>
|
</a>
|
||||||
<a href="#services" class="btn-secondary">
|
|
||||||
{{ __('home.cta_services') }}
|
|
||||||
</a>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- 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>
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-sm lg:text-base font-bold text-text">
|
|
||||||
{{ __('home.values.' . $value . '.title') }}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{-- Latest Posts Section --}}
|
{{-- Vision --}}
|
||||||
@if($latestPosts->count() > 0)
|
<section class="sec fabric" id="mission">
|
||||||
<section id="posts" class="py-16 lg:py-20 bg-background">
|
<div class="wrap">
|
||||||
<div class="container mx-auto px-4">
|
<div class="fabric-grid">
|
||||||
<div class="text-center mb-12">
|
<div data-reveal>
|
||||||
<h2 class="text-2xl lg:text-3xl font-semibold text-text mb-4">
|
<span class="art-no">{{ __('site.art_vision') }}</span>
|
||||||
{{ __('home.posts_title') }}
|
<h2>{{ __('site.vision_title') }}</h2>
|
||||||
</h2>
|
<p class="big">{{ __('site.vision_text') }}</p>
|
||||||
</div>
|
</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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
</div>
|
||||||
@foreach($latestPosts as $post)
|
<div class="three">
|
||||||
<article class="bg-card p-6 rounded-lg shadow-card hover:shadow-card-hover transition-shadow">
|
<article class="card" data-reveal>
|
||||||
<time class="text-xs text-body/70 mb-2 block">
|
<div class="ph motif-ph"><x-site.balance mode="static" :size="150" /><span class="lbl">{{ __('site.card_institution_label') }}</span></div>
|
||||||
{{ $post->published_at?->translatedFormat('j F Y') ?? $post->created_at->translatedFormat('j F Y') }}
|
<div class="bd"><h3>{{ __('site.card_institution_title') }}</h3><p>{{ __('site.card_institution_text') }}</p></div>
|
||||||
</time>
|
</article>
|
||||||
<h3 class="text-lg font-bold text-text mb-3">
|
<article class="card" data-reveal data-d="1">
|
||||||
<a href="{{ route('posts.show', $post) }}" class="hover:text-cta transition-colors" wire:navigate>
|
<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>
|
||||||
{{ $post->getTitle() }}
|
<div class="bd"><h3>{{ __('site.card_society_title') }}</h3><p>{{ __('site.card_society_text') }}</p></div>
|
||||||
</a>
|
</article>
|
||||||
</h3>
|
<article class="card" data-reveal data-d="2">
|
||||||
<p class="text-body text-sm mb-4 line-clamp-3">
|
<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>
|
||||||
{{ $post->getExcerpt() }}
|
<div class="bd"><h3>{{ __('site.card_diaspora_title') }}</h3><p>{{ __('site.card_diaspora_text') }}</p></div>
|
||||||
</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>
|
</article>
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 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
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="book">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="book" data-reveal>
|
||||||
|
<div class="book-l on-dark">
|
||||||
|
<span class="eyebrow">{{ __('site.art_booking') }}</span>
|
||||||
|
<h2>{{ __('site.booking_title') }}</h2>
|
||||||
|
<p>{{ __('site.booking_lede') }}</p>
|
||||||
|
<div class="contact">
|
||||||
|
<a href="tel:+970599353502"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2"/></svg><span dir="ltr">{{ __('footer.phone') }}</span></a>
|
||||||
|
<a href="mailto:{{ __('footer.email') }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg><span>{{ __('footer.email') }}</span></a>
|
||||||
|
<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M12 21s7-6.2 7-12a7 7 0 0 0-14 0c0 5.8 7 12 7 12z"/><circle cx="12" cy="9" r="2.5"/></svg><span>{{ __('footer.address') }}</span></span>
|
||||||
|
</div>
|
||||||
|
<x-site.balance mode="finale" style="width:190px;height:190px;margin-top:22px" />
|
||||||
|
</div>
|
||||||
|
<livewire:pages.booking :compact="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,89 +55,44 @@ new #[Layout('components.layouts.public')] class extends Component
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div class="max-w-4xl mx-auto px-0">
|
<section class="sec" style="padding-top:56px">
|
||||||
<flux:heading size="xl" class="text-body text-xl sm:text-2xl lg:text-3xl">{{ __('posts.posts') }}</flux:heading>
|
<div class="wrap">
|
||||||
|
<x-site.section-head tag="h1" :label="__('site.hero_eyebrow')" :title="__('site.nav_articles')">
|
||||||
<!-- Search Bar -->
|
<div style="position:relative">
|
||||||
<div class="mt-4 sm:mt-6 relative">
|
<label class="sr-only" for="posts-search">{{ __('posts.search_placeholder') }}</label>
|
||||||
<flux:input
|
<input
|
||||||
|
id="posts-search"
|
||||||
|
type="search"
|
||||||
|
class="search"
|
||||||
wire:model.live.debounce.300ms="search"
|
wire:model.live.debounce.300ms="search"
|
||||||
placeholder="{{ __('posts.search_placeholder') }}"
|
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>
|
|
||||||
|
|
||||||
@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>
|
</div>
|
||||||
|
</x-site.section-head>
|
||||||
|
|
||||||
<!-- Search Results Info -->
|
|
||||||
@if($search)
|
@if($search)
|
||||||
<p class="mt-4 text-sm text-body/70">
|
<p class="search-info">
|
||||||
@if($posts->total() > 0)
|
@if($posts->total() > 0)
|
||||||
{{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
|
{{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
|
||||||
@else
|
@else
|
||||||
{{ __('posts.no_results', ['query' => $search]) }}
|
{{ __('posts.no_results', ['query' => $search]) }}
|
||||||
@endif
|
@endif
|
||||||
|
<button type="button" class="btn btn-ghost" wire:click="clearSearch">{{ __('posts.clear_search') }}</button>
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Posts List -->
|
@if($posts->count() > 0)
|
||||||
<div class="mt-6 sm:mt-8 space-y-4 sm:space-y-6">
|
<div class="posts posts-index">
|
||||||
@forelse($posts as $post)
|
@foreach($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">
|
<x-site.post-card :post="$post" wire:key="post-{{ $post->id }}" />
|
||||||
<h2 class="text-lg sm:text-xl font-semibold text-text">
|
@endforeach
|
||||||
<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') }} →
|
|
||||||
</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>
|
|
||||||
@else
|
|
||||||
<flux:icon name="document-text" class="empty-state-icon text-body/40" />
|
|
||||||
<p class="text-body/70">{{ __('posts.no_posts') }}</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforelse
|
|
||||||
</div>
|
</div>
|
||||||
|
@elseif(! $search)
|
||||||
|
<p class="text-center" style="color:var(--muted)">{{ __('posts.no_posts') }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="pagination-wrap">
|
||||||
{{ $posts->links() }}
|
{{ $posts->links() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -18,22 +18,17 @@ new #[Layout('components.layouts.public')] class extends Component
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<article class="max-w-3xl mx-auto">
|
<section class="sec" style="padding-top:56px">
|
||||||
<header class="mb-8">
|
<article class="wrap" style="max-width:768px">
|
||||||
<flux:heading size="xl" class="text-body">{{ $post->getTitle() }}</flux:heading>
|
<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">
|
<div class="prose prose-lg prose-brand max-w-none site-prose">
|
||||||
{{ $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">
|
|
||||||
{!! $post->getBody() !!}
|
{!! $post->getBody() !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="mt-12 pt-6 border-t border-cta/20">
|
<footer style="border-top:1px solid var(--line);margin-top:48px;padding-top:24px">
|
||||||
<a href="{{ route('posts.index') }}" class="text-cta hover:text-cta-hover hover:underline" wire:navigate>
|
<a class="btn btn-ghost" href="{{ route('posts.index') }}" wire:navigate>{{ __('posts.back_to_posts') }}</a>
|
||||||
← {{ __('posts.back_to_posts') }}
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
<x-layouts.public>
|
<x-layouts.public>
|
||||||
<div class="py-8">
|
<section class="sec" style="padding-top:56px">
|
||||||
<h1 class="mb-6 text-3xl font-bold text-text">{{ $page->getTitle() }}</h1>
|
<div class="wrap" style="max-width:768px">
|
||||||
<div class="prose prose-brand max-w-none rounded-lg bg-card p-8 shadow-card" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
<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())
|
@if ($page->getContent())
|
||||||
{!! clean($page->getContent()) !!}
|
{!! clean($page->getContent()) !!}
|
||||||
@else
|
@else
|
||||||
<p class="text-body">{{ __('pages.content_coming_soon') }}</p>
|
<p>{{ __('pages.content_coming_soon') }}</p>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-sm text-body/70">
|
<p class="legal-updated">
|
||||||
{{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
|
{{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</x-layouts.public>
|
</x-layouts.public>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<x-layouts.public>
|
<x-layouts.public>
|
||||||
<div class="py-8">
|
<section class="sec" style="padding-top:56px">
|
||||||
<h1 class="text-3xl font-bold text-text mb-6">{{ __('footer.privacy') }}</h1>
|
<div class="wrap" style="max-width:768px">
|
||||||
<div class="bg-card p-8 rounded-lg shadow-card prose prose-brand max-w-none">
|
<h1 style="font-size:clamp(2rem,3.4vw,3rem);margin:0 0 28px">{{ __('footer.privacy') }}</h1>
|
||||||
<p class="text-body">{{ __('Privacy policy content will be added here.') }}</p>
|
<div class="prose prose-brand max-w-none site-prose legal-card">
|
||||||
|
<p>{{ __('Privacy policy content will be added here.') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</x-layouts.public>
|
</x-layouts.public>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<x-layouts.public>
|
<x-layouts.public>
|
||||||
<div class="py-8">
|
<section class="sec" style="padding-top:56px">
|
||||||
<h1 class="text-3xl font-bold text-text mb-6">{{ __('footer.terms') }}</h1>
|
<div class="wrap" style="max-width:768px">
|
||||||
<div class="bg-card p-8 rounded-lg shadow-card prose prose-brand max-w-none">
|
<h1 style="font-size:clamp(2rem,3.4vw,3rem);margin:0 0 28px">{{ __('footer.terms') }}</h1>
|
||||||
<p class="text-body">{{ __('Terms of service content will be added here.') }}</p>
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
</x-layouts.public>
|
</x-layouts.public>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
|
<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="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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('© '.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');
|
||||||
|
});
|
||||||
@@ -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}');
|
||||||
|
});
|
||||||
@@ -170,13 +170,6 @@ describe('Footer Component', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Language Toggle in Navigation', 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 () {
|
test('switching to Arabic applies RTL layout', function () {
|
||||||
$this->get(route('language.switch', 'ar'))
|
$this->get(route('language.switch', 'ar'))
|
||||||
->assertRedirect();
|
->assertRedirect();
|
||||||
@@ -220,16 +213,16 @@ describe('Footer Translations', function () {
|
|||||||
test('English footer translations are loaded', function () {
|
test('English footer translations are loaded', function () {
|
||||||
expect(__('footer.terms', [], 'en'))->toBe('Terms of Service');
|
expect(__('footer.terms', [], 'en'))->toBe('Terms of Service');
|
||||||
expect(__('footer.privacy', [], 'en'))->toBe('Privacy Policy');
|
expect(__('footer.privacy', [], 'en'))->toBe('Privacy Policy');
|
||||||
expect(__('footer.copyright', [], 'en'))->toBe('Libra Law Firm. All rights reserved.');
|
expect(__('footer.copyright', [], 'en'))->toBe('Libra for Rights. All rights reserved.');
|
||||||
expect(__('footer.address', [], 'en'))->toBe('American Building - Next to Al-Shini - 4th Floor');
|
expect(__('footer.address', [], 'en'))->toBe('Jaffa Street - Next to Al-Shini - American Building - 4th Floor');
|
||||||
expect(__('footer.phone', [], 'en'))->toBe('+970 599 353 502');
|
expect(__('footer.phone', [], 'en'))->toBe('+970 599 353 502');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Arabic footer translations are loaded', function () {
|
test('Arabic footer translations are loaded', function () {
|
||||||
expect(__('footer.terms', [], 'ar'))->toBe('شروط الخدمة');
|
expect(__('footer.terms', [], 'ar'))->toBe('شروط الخدمة');
|
||||||
expect(__('footer.privacy', [], 'ar'))->toBe('سياسة الخصوصية');
|
expect(__('footer.privacy', [], 'ar'))->toBe('سياسة الخصوصية');
|
||||||
expect(__('footer.copyright', [], 'ar'))->toBe('مكتب ليبرا للمحاماة. جميع الحقوق محفوظة.');
|
expect(__('footer.copyright', [], 'ar'))->toBe('مكتب ليبرا للحقوق. جميع الحقوق محفوظة.');
|
||||||
expect(__('footer.address', [], 'ar'))->toBe('العمارة الأمريكية - بجانب الشني - الطابق الرابع');
|
expect(__('footer.address', [], 'ar'))->toBe('شارع يافا - بجانب الشني - العمارة الأمريكية - الطابق الرابع');
|
||||||
expect(__('footer.phone', [], 'ar'))->toBe('+970 599 353 502');
|
expect(__('footer.phone', [], 'ar'))->toBe('+970 599 353 502');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,18 +19,12 @@ test('about page uses public layout', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC3: Hero Section Container
|
// 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
|
// AC4: Page Title
|
||||||
test('about page displays title in English', function () {
|
test('about page displays title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
->get('/about')
|
->get('/about')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('About Us');
|
->assertSee(__('site.nav_about', [], 'en'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('about page displays title in Arabic', function () {
|
test('about page displays title in Arabic', function () {
|
||||||
@@ -56,20 +50,6 @@ test('about page displays tagline in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC6: Identity Statement
|
// 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)
|
// AC8: RTL Support - Verified via locale setting (RTL is handled by layout)
|
||||||
test('about page sets correct language direction for Arabic', function () {
|
test('about page sets correct language direction for Arabic', function () {
|
||||||
$this->withSession(['locale' => 'ar'])
|
$this->withSession(['locale' => 'ar'])
|
||||||
@@ -90,7 +70,7 @@ test('navigation contains about us link in English', function () {
|
|||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
->get('/')
|
->get('/')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('About Us')
|
->assertSee(__('site.nav_about', [], 'en'))
|
||||||
->assertSee('href="'.route('about').'"', false);
|
->assertSee('href="'.route('about').'"', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,12 +97,6 @@ test('mobile navigation contains about us link', function () {
|
|||||||
// Story 16.2: Vision & Mission Section Tests
|
// Story 16.2: Vision & Mission Section Tests
|
||||||
|
|
||||||
// AC1: Vision Section Container
|
// 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
|
// AC2: Vision Section Header
|
||||||
test('about page displays vision title in English', function () {
|
test('about page displays vision title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -139,27 +113,7 @@ test('about page displays vision title in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC3: Vision Statement
|
// 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
|
// 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
|
// AC5: Mission Section Header
|
||||||
test('about page displays mission title in English', function () {
|
test('about page displays mission title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -168,13 +122,6 @@ test('about page displays mission title in English', function () {
|
|||||||
->assertSee('Our Mission');
|
->assertSee('Our Mission');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('about page displays mission title in Arabic', function () {
|
|
||||||
$this->withSession(['locale' => 'ar'])
|
|
||||||
->get('/about')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('مهمتنا');
|
|
||||||
});
|
|
||||||
|
|
||||||
// AC6: Mission Cards - Institution
|
// AC6: Mission Cards - Institution
|
||||||
test('about page displays institution mission card in English', function () {
|
test('about page displays institution mission card in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -193,22 +140,6 @@ test('about page displays institution mission card in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC6: Mission Cards - Society
|
// 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
|
// AC6: Mission Cards - Diaspora
|
||||||
test('about page displays diaspora mission card in English', function () {
|
test('about page displays diaspora mission card in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -227,19 +158,7 @@ test('about page displays diaspora mission card in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC7: Mission Card Styling
|
// 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
|
// 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)
|
// AC7: Icons present (Flux renders icons as inline SVGs)
|
||||||
test('about page mission cards have icons', function () {
|
test('about page mission cards have icons', function () {
|
||||||
$this->get('/about')
|
$this->get('/about')
|
||||||
@@ -250,12 +169,6 @@ test('about page mission cards have icons', function () {
|
|||||||
// Story 16.3: Values Section Tests
|
// Story 16.3: Values Section Tests
|
||||||
|
|
||||||
// AC1: Values Section Container
|
// 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
|
// AC2: Values Section Header
|
||||||
test('about page displays values title in English', function () {
|
test('about page displays values title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -373,28 +286,10 @@ test('about page displays social innovation value in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC5: Value Card Styling
|
// 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
|
// 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
|
// Story 16.4: Goals Section Tests
|
||||||
|
|
||||||
// AC1: Goals Section Container
|
// 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
|
// AC2: Goals Section Header
|
||||||
test('about page displays goals title in English', function () {
|
test('about page displays goals title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -511,26 +406,8 @@ test('about page displays goal 7 in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC4: Goals Card Container Styling
|
// 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
|
// 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
|
// 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
|
// AC7: All 7 goal numbers are displayed
|
||||||
test('about page displays all 7 goal numbers', function () {
|
test('about page displays all 7 goal numbers', function () {
|
||||||
$response = $this->withSession(['locale' => 'en'])->get('/about');
|
$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
|
// Story 16.5: Legal Services Section Tests
|
||||||
|
|
||||||
// AC1: Services Section Container
|
// 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
|
// AC2: Services Section Header
|
||||||
test('about page displays services title in English', function () {
|
test('about page displays services title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -565,20 +436,6 @@ test('about page displays services title in Arabic', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// AC3: Services Introduction
|
// 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
|
// AC4: Four Service Categories - Card 1: Legal Consultations & Representation
|
||||||
test('about page displays consultation service card title in English', function () {
|
test('about page displays consultation service card title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
@@ -703,42 +560,8 @@ test('about page displays business service card title in Arabic', function () {
|
|||||||
->assertSee('الخدمات القانونية للأعمال');
|
->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
|
// 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
|
// 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
|
// AC7: CTA Button
|
||||||
test('about page services section has CTA button in English', function () {
|
test('about page services section has CTA button in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$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
|
// Story 16.6: Scholarship Aspiration Section Tests
|
||||||
|
|
||||||
// AC1: Scholarship Section Container
|
// 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
|
// 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
|
// 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
|
// 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)
|
// 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)
|
// 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
|
// 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
|
// 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);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ test('logged in user sees message to book from dashboard', function () {
|
|||||||
test('guest booking page shows calendar', function () {
|
test('guest booking page shows calendar', function () {
|
||||||
$this->get(route('booking'))
|
$this->get(route('booking'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee(__('booking.guest_intro'));
|
->assertSee('calendar-grid', false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('guest can select a slot and see contact form', function () {
|
test('guest can select a slot and see contact form', function () {
|
||||||
|
|||||||
@@ -83,12 +83,6 @@ test('home page contains services section with id', function () {
|
|||||||
|
|
||||||
// About Section Tests
|
// 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 () {
|
test('home page displays about section title in English', function () {
|
||||||
$this->withSession(['locale' => 'en'])
|
$this->withSession(['locale' => 'en'])
|
||||||
->get('/')
|
->get('/')
|
||||||
@@ -188,70 +182,6 @@ test('home page displays services section subtitle in Arabic', function () {
|
|||||||
->assertSee('حلول قانونية شاملة للأفراد والشركات');
|
->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
|
// Values Section Tests
|
||||||
|
|
||||||
test('home page contains values section with id', function () {
|
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
|
// 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 () {
|
test('home page displays posts section when published posts exist', function () {
|
||||||
Post::factory()->published()->count(3)->create();
|
Post::factory()->published()->count(3)->create();
|
||||||
|
|
||||||
@@ -407,22 +330,6 @@ test('home page displays posts section title in Arabic', function () {
|
|||||||
->assertSee('أحدث المقالات');
|
->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 () {
|
test('home page displays post titles', function () {
|
||||||
$post = Post::factory()->published()->create([
|
$post = Post::factory()->published()->create([
|
||||||
'title' => ['en' => 'Test English Title', 'ar' => 'عنوان اختبار'],
|
'title' => ['en' => 'Test English Title', 'ar' => 'عنوان اختبار'],
|
||||||
@@ -565,3 +472,18 @@ test('home page displays footer address with street name in Arabic', function ()
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('شارع يافا - بجانب الشني - العمارة الأمريكية - الطابق الرابع');
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -172,72 +172,12 @@ describe('Forms Responsive', function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Footer 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 () {
|
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 () {
|
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 () {
|
describe('Consultations Page Responsive', function () {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
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,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user