# CLAUDE.md

Guidance for Claude Code (claude.ai/code) when working in this repository.

## What this is

The ABACO INFRA website, content management system **and multi-sector marketplace**: a
server-rendered Laravel + Blade application with a public site, a buyer/seller marketplace and a
role-based admin panel.

**Property leads the site.** The homepage opens on a property search, `Properties` heads the
navigation, and the engineering-consultancy modules from the SOW (sectors, projects, team, media,
careers) remain as supporting content. Knight Frank is the agreed reference for information
architecture; its patterns are adopted, its copy and imagery are not.

Authoritative references, in order:

1. `docs/SOW.md` — the client-approved Scope of Work. Module lists, field-level requirements and the
   delivery timeline. Read the relevant section before building a module; never infer requirements
   from a module name.
2. `docs/DESIGN.md` — the design system of record: tokens, typography, layout, imagery, motion,
   accessibility, performance and the definition of done. All frontend work follows it.
3. `docs/MARKETPLACE.md` — the marketplace's architecture and the reasoning behind it, phase by
   phase. Read it before changing the listing engine, the taxonomy, billing or moderation; it records
   why each decision went the way it did, including the ones that were wrong first.
4. `docs/DEPLOYMENT.md` — putting it live and keeping it running.

## Technology stack

Fixed — do not substitute without asking.

- **Backend:** Laravel 12 (PHP 8.2+), Blade
- **Frontend:** Tailwind CSS v4, Alpine.js, GSAP + ScrollTrigger. No SPA framework.
- **Database:** MySQL
- **Mail:** SMTP
- **Hosting:** cPanel or VPS/cloud; SSL certificate supplied by the client

Key packages: `spatie/laravel-permission` (roles), `mews/purifier` (rich-text sanitising),
`intervention/image` (uploads), `anhskohbo/no-captcha` (form spam protection),
`maatwebsite/excel`, `barryvdh/laravel-dompdf`, `rinvex/countries`, `anuzpandey/laravel-nepali-date`.

"API setup and integration" in the SOW means internal endpoints that return server-rendered Blade
fragments for filters, galleries and search suggestions — not a public JSON API.

## Commands

```bash
composer install && npm install
cp .env.example .env && php artisan key:generate && php artisan storage:link

php artisan serve                 # dev server
npm run dev                       # Vite watch
npm run build                     # production assets

php artisan migrate
php artisan migrate:fresh --seed  # rebuild with structure + placeholder content
php artisan db:seed --class=RolePermissionSeeder   # re-sync permissions after editing config/modules.php
php artisan db:seed --class=UserSeeder             # re-sync admin accounts and their roles
php artisan db:seed --class=AmenitySeeder          # property amenity vocabulary
php artisan db:seed --class=MarketplaceTaxonomySeeder  # sectors, categories, attributes
php artisan db:seed --class=AdditionalSectorsSeeder    # the verticals beyond property (optional)
php artisan db:seed --class=PlanSeeder                 # subscription tiers

# Scheduled work, runnable by hand
php artisan marketplace:expire-listings
php artisan marketplace:sweep-promotions
php artisan marketplace:renew-subscriptions --dry-run
php artisan marketplace:migrate-properties          # legacy properties into the listing engine

php artisan test                                    # full suite
php artisan test --filter=ProjectModuleTest         # one class
php artisan test tests/Feature/CareerTest.php       # one file

./vendor/bin/pint                 # format

# Pint needs more than PHP's default 128M on this codebase:
php -d memory_limit=1G vendor/bin/pint
```

**Production seeding:** run `RolePermissionSeeder`, `UserSeeder`, `SettingSeeder`, `SectorSeeder`,
`StructureSeeder`, `AmenitySeeder`, `LocationSeeder`, `MarketplaceTaxonomySeeder`, `PlanSeeder` and
`MenuSeeder`. `AdditionalSectorsSeeder` adds the non-property verticals and is optional — leave it
out to launch with property alone. `DemoContentSeeder` and `DemoPropertySeeder` hold placeholder
projects, clients, statistics and listings for design review; both refuse to run when
`APP_ENV=production`.

`PlanSeeder` must run before any seller subscribes: `SubscriptionService::planFor()` falls back to
the default plan, so without it a seller is on no terms at all rather than on the free tier.

`UserTableSeeder` creates the system account (`superadmin@abacoinfra.com`, username `superadmin`)
and issues it one Sanctum API token, printed once — Sanctum stores a hash, so it cannot be shown
again. It reads `SUPERADMIN_EMAIL`/`SUPERADMIN_PASSWORD` through `config/accounts.php` and must run
after `RolePermissionSeeder`.

`UserSeeder` creates one account per role — `admin@`, `editor@` and `media@abacoinfra.com` — reading
`ADMIN_PASSWORD`, `CONTENT_EDITOR_PASSWORD` and `MEDIA_MANAGER_PASSWORD` from the environment and
falling back to `ChangeMe!2026` with a warning. It must run after `RolePermissionSeeder` (the roles
have to exist first) and is safe to re-run: existing passwords are never overwritten, only role
assignments are re-synced.

## Architecture

### One login, two audiences

Authentication is **Laravel Breeze**, in `routes/auth.php` on Breeze's own route
names and controllers. There is one `/login` for the whole site; where somebody
lands afterwards is decided by what kind of account they hold, not by which URL
they knocked on — staff to `/admin`, everyone else to `/account`. `/admin` is
shut to non-staff by the `staff` middleware on its route group, not by having a
separate door.

Two things differ from stock Breeze and both are deliberate:

- **The login field takes a username or an email.** `LoginRequest::identifier()`
  picks the column on the presence of `@`, which is safe only because
  `RegisterRequest` forbids `@` in a username. Those two rules have to be read
  together.
- **There is no `dashboard` route.** Breeze's controllers were rewired to
  `admin.dashboard` or `account.dashboard` depending on the account.

Staff are `account_type = staff`; buyers and sellers hold a `buyer_profiles` row,
plus a `seller_profiles` row once they list.

### Roles and permissions

Role names are **slugs** — `superadmin`, `content-editor`, `media-manager`,
`buyer`, `seller` — and the human wording lives in `config/modules.php` under
`role_labels`. A role name is an identifier that ends up in seeders, middleware
strings and policy checks, where `hasRole('Super Admin')` is one stray space away
from silently granting nothing. Always use the `User::ROLE_*` constants.

**Two permission vocabularies, deliberately kept apart:**

| | Module permissions | Marketplace permissions |
| --- | --- | --- |
| Example | `listings.moderate`, `settings.update` | `marketplace.listings.create`, `marketplace.leads.manage` |
| Declared in | `modules` + `abilities` | `marketplace_permissions` |
| Granted to | staff roles | `buyer` / `seller` at registration |
| Opens | an admin screen | nothing in the panel, ever |

**Registration is where a marketplace account gets its permissions.** The form
asks what the visitor wants to do — *Buy or rent* / *Sell or rent out* — and that
one answer decides the account type, the profiles opened and the role. It all
goes through `AccountService::register()`, the only place a profile and a role
are granted together, so the two can never disagree. A seller keeps the buyer
half: in practice the same person does both.

**Permissions and policies both apply, and neither is redundant.** The permission
says *what kind of thing* this account may do — the half a registration form can
decide, with no record in hand. The policy says *whether this record is theirs* —
which no permission can express. `ListingPolicy::update()` asks both.

Marketplace permissions are **prefixed `marketplace.`** and that prefix is
load-bearing. Without it `listings.create` means two different things — a key to
the admin listing screen and a seller's right to post one — and a Content Editor
granted the module silently satisfies any check for the seller's right. That
collision was live for about an hour during this work and `RolePermissionTest`
now holds the two vocabularies apart in both directions.

Nothing a visitor can select at registration grants a panel permission;
`RegistrationTest` holds that line.

### Two halves, one codebase

Every functional area exists twice: a public view and an admin CRUD screen. A new content type
without an admin screen is not deliverable — the client maintains their own content. The staff roles
are `superadmin`, `content-editor` and `media-manager`, with per-module permissions.

### config/modules.php is the admin panel

Each row generates four permissions (`view/create/update/delete`), one sidebar link and the role
presets. Adding a module means adding a row and re-running `RolePermissionSeeder`.

### The generic CRUD layer

`App\Http\Controllers\Admin\AdminCrudController` implements list/search/filter/create/edit/
soft-delete/publish-toggle/reorder once. A module supplies a subclass (model, permission key,
validation `rules()`, index `columns()`) plus `resources/views/admin/<module>/form.blade.php`.
Modules with child records add `form-relations.blade.php`. Authorisation happens in `callAction()`,
so every action is gated without repeating middleware on routes.

Subclass hooks: `formData()` for dropdowns, `afterSave()` for repeaters and child records,
`transientFields()` for validated inputs that are not columns, `richTextFields()` for HTML that
must be purified, `beforeDelete()` for file cleanup and delete guards.

### Content model conventions

`App\Concerns\HasSlug` (stable slugs — an existing slug is never rewritten), `HasContentScopes`
(`active()`, `ordered()`, `featured()`, `forSector()`, `search()`) and `HasSeo` (meta fallbacks).
Sector is the dominant filter dimension: any model with `sector_id` gets `forSector()` for free.

### The homepage is composed, not hardcoded

`config/homepage.php` declares the sections; `App\Services\HomepageSections` resolves order and
visibility from the `homepage_sections` setting; editors change both at Admin → Home Page →
Sections & Order. `resources/views/public/home.blade.php` loops the resolved keys and includes
`public/sections/<key>.blade.php`. Sections read from their own modules — never duplicate project,
media or achievement content in a Blade file.

### The marketplace engine

**The heart of the application, and the thing to understand before changing anything.** The full
reasoning is in `docs/MARKETPLACE.md`; this is the shape.

```
Marketplace → Sector → Category → Subcategory → Attributes → Listing
```

`listings` is the one table every vertical shares. What differs between a house and a tractor is
**data**, not code: `marketplace_attributes` are bound to categories and inherited downward, and
`App\Services\Marketplace\AttributeService` resolves them into a form, a validation rule set and a
filter panel. Eight verticals beyond property were added in phase 17 **without a single migration**.

Do not add a column to `listings` for a field that belongs to one vertical. That is what the
attribute engine is for, and the moment it is bypassed the next vertical costs a migration again.
Real estate is the one exception — `listing_property_details` is a *projection* maintained by
`PropertyProjector` purely so the busiest filters have an index to hit, never a second source of
truth.

Points worth knowing before touching it:

- **All money is in minor units.** Listings, plans, payments, promotions, invoices. Forms work in
  major units and convert on the way in and out.
- **`Listing::scopeLive()`** is what the public sees: published, and not expired. The old
  `Property` CRUD was retired in phase 6. The `Property`, `PropertyImage`, `PropertyFloorPlan` and
  `PropertyEnquiry` models still exist, but **only** so `marketplace:migrate-properties` can read the
  legacy tables. Nothing public or admin-facing touches them; do not build on them.
- **`Listing::publicUrl()`** is the only place a listing URL is built, and it picks its shape from
  the listing's own sector. Never assemble one by hand.
- **`App\Services\Marketplace\SearchService`** owns the query-string contract, shared by the
  listings page, the AJAX fragment, the map endpoint, saved searches and the homepage band. Dynamic
  attribute filters travel as `attr[code]=value`.
- **`ListingStateMachine` is the only place a status changes**, and it answers two separate
  questions: is this transition legal, and may *this person* make it.
- **References** (`PR-0001`) are generated on create and quoted in enquiries.
- Enquiries, viewing requests, phone reveals and offers all become one `Lead`. One inbox, because a
  seller who has to check four checks none.

### Billing

`plans`, `subscriptions`, `payments`, `invoices`, `refunds`, `listing_promotions`. Two rules:

- **Nothing is granted until a payment settles.** A subscription is activated by `PaymentSettled`,
  never by pressing a button; a promotion runs only while a paid row says it does.
- **Nothing outside `app/Payments` may name a gateway.** `App\Contracts\PaymentGateway` is the whole
  coupling. Adding a processor is one class and one row in `config/payments.php`.

### Content modules

Projects (sector, status, flagship/donor-funded/international, media, documents) · Sectors (the
primary taxonomy) · Media (notices, publications, events, media, stories — one table, split by
`type`) · Team (hierarchical groups) · Downloads (type-derived icons, categories, tracking) ·
Gallery (albums with photos and video) · Careers (jobs + applications with CV upload) · Clients &
Partners · Achievements & Accreditation · FAQs · Quick Links · Newsletter · Contact/feedback
(one inbox for contact, inquiry, feedback and grievance) · Pages with a block builder · Offices ·
Homepage pieces (hero slides, announcements, counters, highlights, impact slides, testimonials).

### Marketplace modules

Sectors, Categories and Listing Attributes (the taxonomy) · Listing Moderation · Verification
(private documents, badges) · Moderation (a dashboard over four queues, reviews, reports and the
audit log) · Accounts (marketplace accounts and suspensions) · Plans · Payments (the ledger,
reconciliation and refunds).

Seller-facing screens live under `/account`: listings, media, leads, messages, viewings, offers,
reviews, saved searches, favourites, analytics and billing. A buyer sees the subset that applies to
them — the side navigation filters itself, so a buyer is never shown an empty listings screen.

### Cross-cutting

- **Uploads** go through `App\Services\FileUploadService`: per-category extension *and* sniffed MIME
  allow-lists, regenerated filenames, image re-encoding, thumbnails, cascade delete. Never write an
  upload straight to disk.
- **Rich text** is purified on the way in (`richTextFields()`), so stored HTML is safe to render.
- **Public forms** use `App\Concerns\ProtectsPublicForms`: honeypot, minimum time-on-form, optional
  reCAPTCHA (only when keys are set), plus route-level rate limiting.
- **Search** lives in `App\Services\SiteSearch` and backs both the results page and the header's
  instant suggestions; it always goes through each module's published scopes.
- **Layout data** (menus, sectors, offices, accreditations, quick links, announcement) comes from
  `App\View\Composers\LayoutComposer`, cached and flushed by `LayoutComposer::flush()`.
- **Lazy loading is disabled outside production.** Eager-load relations used in views, or tests fail.
- **Navigation** is admin-managed and capped at three levels.
- **The admin panel is staff-only at the door.** `routes/admin.php` carries `auth`, `active` **and**
  `staff`; per-module permissions are checked inside each controller on top of that. Before public
  registration existed `auth` alone was enough — it is not any more, and the dashboard has no gate of
  its own.
- **Private files never touch the public disk.** Verification documents, land deeds and message
  attachments go to the `private` disk, which has no URL, and are served only through a route that
  runs a policy first.
- **Structured data goes through `ld_json()`**, never a bare `json_encode`. Without `JSON_HEX_TAG` a
  listing title containing `</script>` breaks out of the block.
- **Visitor terms reaching a `LIKE` go through `like_term()`**, which escapes `%` and `_`. Otherwise a
  search for `%` returns the whole catalogue.
- **Every decision a moderator makes is written to `audit_logs`** by `App\Services\AuditLogger`,
  append-only, so "who took this down, and why" is answerable a month later.

## Frontend

- Tokens live in `resources/css/app.css` under `@theme`. **Tailwind v4 is CSS-first — this project
  has no `tailwind.config.js`.** Never hardcode a hex value in a Blade file.
- Brand: ABACO orange `#FF9A2E` + charcoal `#333333`, from the brand board and the logo SVGs.
  Orange scores ~2:1 on white, so it is a **surface** colour only — solid buttons and pills carry
  near-black text, and interactive text uses `text-text-brand` (`#A35600`, 4.6:1). Never write
  `text-primary` on a light background.
- Typeface is Poppins throughout, with Fira Code for `.meta-label` technical metadata.
- Logo files: `public/images/logo-abaco-horozontal.svg` (light backgrounds),
  `logo-abaco-horizontal-reversed.svg` (dark), `favicon-abaco.svg`. They are wired through the
  `site_logo`, `site_logo_light` and `site_favicon` settings, so the client can replace them
  without a code change.
- Tailwind v4 only allows `@apply` of *utilities*: shared bases (`btn`, `pill`, `card`) are declared
  with `@utility`; variants build on them inside `@layer components`.
- Two bundles: `resources/js/app.js` (public — Alpine + GSAP motion) and `resources/js/admin.js`
  (admin — Alpine + Trix). Visitors never download the editor.
- Breakpoints run `xs` (360px) through `4xl` (2560px). Card grids carry `3xl:`/`4xl:` column counts
  and `.container-page` widens to `max-w-8xl`/`max-w-9xl`, so an ultrawide screen is not a ribbon of
  content in the middle of the viewport. Tight two-column blocks stack below `xs`.
- **Every `<select>` is a select2 field.** `resources/js/select2.js` lazily imports jQuery + select2
  the first time a dropdown exists on the page (including ones that arrive with an AJAX fragment),
  so pages without one never pay the ~50 kB. The plugin's own look is overridden in `app.css` to
  match `.form-input`. Two escape hatches on the element: `data-no-select2` leaves a field native,
  `data-searchable` forces the search box on (it appears automatically above ten options).
  select2 fires jQuery events, which Alpine's `@change` never sees, so the module re-dispatches a
  real DOM `change` — do not remove that.
- Motion is declarative: `data-reveal`, `data-reveal-group`/`data-reveal-child`, `data-parallax`,
  `data-zoom-scroll`, `data-mask-reveal`, `data-count-to`, `data-pin-media`/`data-pin-scope`,
  `data-hscroll`/`data-hscroll-track`, `data-progress-bar`. `resources/js/motion.js` registers
  nothing at all under `prefers-reduced-motion: reduce`.

### Image system

Imagery carries the brand, so it is a closed vocabulary rather than a free-for-all. Twenty
sanctioned techniques, each with one home — the full table, the per-area mapping and the explicitly
rejected techniques are in `docs/DESIGN.md` §4. Do not introduce a twenty-first without a reason,
and turn any repeat into a component first.

Building blocks already in place:

| Need | Use |
| --- | --- |
| Cinematic hero | `public/partials/hero.blade.php` (Ken Burns, video, badges) |
| Any framed image | `<x-image>` — ratio, thumbnail, lazy/eager, hover zoom, fallback |
| Split editorial band | `<x-split-media>` |
| Full-bleed aerial band | `<x-immersive-band>` (parallax + zoom + gradient) |
| Masonry set | `<x-masonry>` + `.masonry-item` children |
| Horizontal scroll story | `<x-scroll-gallery>` (pins on desktop, swipes below) |
| Before / after | `<x-compare-slider>` (clip-path reveal, keyboard range control) |
| Annotated drawing | `<x-hotspot-image>` (also lists every note linearly) |
| Full-screen viewer | `public/partials/lightbox.blade.php` with `x-data="lightbox"` |
| Technical texture | `.bg-blueprint`, `.bg-blueprint-dark`, `.bg-dot-grid`, `.bg-topographic` |
| Legibility over photos | `.scrim`, `.scrim-side` |

Page-builder block types that surface these to editors: `immersive`, `gallery`, `compare`,
`hotspots`, plus `text`, `text_image`, `stats`, `timeline`, `accordion`, `cta`, `embed`. Each has a
view in `resources/views/public/blocks/`; an unknown type falls back to `text`. Block data lives in
`page_blocks.data` (`rows`, `images`, `embed_url`, labels) with `image`/`image_secondary` columns
for the two-image types.

Non-negotiables: the LCP hero image is never lazy-loaded and carries `fetchpriority="high"`;
everything below the fold is `loading="lazy" decoding="async"`; hover is never the only route to
content (hotspots and team cards both duplicate their content in text); uploads always resolve
through `image_url()`/`thumb_url()` so a missing file renders the drafting-grid placeholder rather
than a broken image.
- Reusable components live in `resources/views/components/`. Check for an existing one before
  writing a new pattern.
- User-facing strings go through `__()` — the site must stay translatable to Nepali.

## Testing

675 tests, all passing. Three suites are worth knowing about before changing anything:

- **`QueryBudgetTest`** asserts that a page's query count does not grow with the number of rows on
  it. It measures the *second* request, not the first — the layout composer and the attribute
  vocabulary cache on first use, so comparing a cold page against a warm one reports an improvement
  where there was none.
- **`SecurityAuditTest`** is written as attacks rather than as assertions about intent: every test
  tries to do something it should not be able to do. Add one whenever a new surface accepts input or
  serves a record somebody else owns.
- **`PublicPageSmokeTest`** renders every public page *with rows on it*. A page tested against an
  empty database never renders the row that would have broken it — which is how a listing card took
  the home page down.
- **`ProductionReadinessTest`** guards mistakes that only appear on a live server — an `env()` call
  outside `config/`, a module pointing at a route that does not exist, a scheduled job nobody
  registered, a demo seeder that would run against real data.

### The two traps that let production break while the suite was green

Both are worth knowing before trusting a passing test.

**SQLite quotes an unknown column as a string literal.** The suite runs on SQLite; production is
MySQL. `where('is_published', true)` against a table with no such column is a fatal error on MySQL
and, on SQLite, compares the *string* `'is_published'` to `1` — no error, no rows, a green test and a
500 on the server. So a query's **result** must be asserted, never just the status code of the page
around it. That is what took `/sitemap.xml` down.

**A `belongsTo` whose foreign key was not selected returns null without querying.** Eager-loading
`category:id,name,slug` and then reading `$category->sector` gives you null — silently, with no
lazy-loading violation, because Laravel never runs the query. Select the whole row and the same code
throws `LazyLoadingViolationException` instead. The same line therefore behaves three different ways
depending on what the caller selected, which is why `Listing::publicUrl()` now resolves everything
from `TaxonomyMap` and touches no relation at all.

## Known issues in docs/SOW.md

The SOW was adapted from a school-website template and keeps artefacts that do not apply here.
Treat these as template residue, not requirements — flag them to the client rather than building
them literally:

- SOW 12 categorises FAQs as "Admission, Fees" (implemented as Services, Procurement, Careers,
  General in `StructureSeeder`).
- SOW 15 describes the newsletter as "Email form for parents/students".
- SOW D.1 mentions a "kickoff meeting with school representatives".
- SOW D.6 mentions "training for school staff".

Section D.4 labels its final implementation block "Week 10" while section C allots weeks 4–9 to
implementation — confirm which is authoritative before planning against it.

## Content integrity

Never invent project statistics, clients, certifications, awards, dates or technical claims. Where
real content is unavailable, use clearly marked placeholder copy. All counters come from
admin-managed data.
