# Project Detail (Main · Public Site)

**Route:** `GET /{country}/projects/{slug}` · **Reference UI:** peta-new
`my-portal/project.blade.php`

## What it does

The catalogue project page is one shared page for guests and members. It shows
the project gallery and eight tabs: Overview, Units · Price, Investment
Analysis, Financial Projection, New Supply, Amenities, Developer Record and
Contact Advisor. Member-only payloads are omitted server-side for guests and
render through `LockedSection`; there is no second member page.

Project Detail uses a permanent **provider-separated** read model. Provider
selection is by the fact being requested, not by `market_segments` and not by
whichever source most recently updated the canonical row:

- **PropertySifu** owns official new-project facts: layouts, launch prices,
  layout media, layout rent facts and the PropertySifu developer label.
- **EdgeProp** owns market evidence: nearby asking-price/rental comparables and
  the amenity payload used by market analysis.
- **`petav2_legacy`** is provenance/compatibility evidence only. Its provider
  rows are never mixed into official units or current EdgeProp comparable
  selection.
- **`market_airbnbs`** owns Airbnb comparable data.

One project may have both `new_project` and `subsale` memberships. That does
not change the provider rules above. Never add project-name/slug exceptions to
repair a mismatched project.

The former `?source_preview=1` switch has been retired. Provider-separated
behavior is the normal path and the query parameter has no effect; do not
reintroduce a preview branch.

## How it works

### Shared page and unit selection

`CatalogueDetailService::forSlug()` returns the page contract. Official units
come from `CatalogProject::officialFloorPlans(propertysifu)`, which keeps
active PropertySifu-backed plans when that source exists **plus the project's
admin-owned plans**, and collapses duplicate `unitTypes`/`layouts` evidence
by canonical plan identity. A project outside PropertySifu coverage falls back
to its canonical floor plans.

**Admin-owned plans publish (2026-08-28).** A provider feed carries the layouts
it happened to publish, not the project's full unit catalogue, and the admin
floor-plan form exists to finish it — so `CatalogFloorPlan::isAdminOwned()`
survives the provider filter. It means **no ACTIVE source backs the plan AND a
human has curated it** (`manual_overrides` non-empty).

Both halves are load-bearing. Unbacked alone also describes the generic market
rows a re-derive leaves behind (`2 Bedroom`, `3 Bedroom`, no overrides — 222
contribution-less ones, plus 302 whose only evidence is a switched-off source,
79 of those on published projects). Curated alone also describes a row a live
feed still syncs and an admin merely corrected. Neither is this project's
official unit type.

Backing is judged by whether the contributing project source is **active**, not
by whether a contribution row exists: a plan whose only evidence comes from a
source the catalogue no longer syncs — `petav2_legacy` above all — is as
unbacked as one with no evidence at all.

Admin-owned plans sort AFTER the provider's list, by name, because floor-plan
row ids share no ordering with a feed's layout ids. One that duplicates a
provider layout's (name, sqft) merges into it rather than doubling the unit.

Two reports, one filter, and neither screen said why:

- **ARRA Residences** published 1 of its 5 layouts — the other four were typed
  by hand, so no feed had ever contributed them.
- **Branniganz Suites** published 1 of its 4 — its curated A/B/C were left
  backed only by the inactive `petav2_legacy` source once the PropertySifu rows
  duplicating them were removed. **Deleting a duplicate can unpublish the row
  it duplicated**, and nothing warned: merging carries the provider
  contribution onto the survivor, deleting does not.

The Review page's publish preview had kept contribution-less plans all along
(`CatalogReviewController::hasReviewProvider()`); the public read model had
not, and the two disagreeing is what hid the first case — the held-back rows
were labelled "duplicate", which they plainly were not.

The selected official plan drives Units, Investment Analysis and Financial
Projection. New Supply and Amenities are project/location data and must not
disappear merely because the selected plan is incomplete.

### Tab data contract

| Tab | Data source and query rule |
|---|---|
| **Overview** | Canonical project identity, hero/location and general fields come through `CatalogueDetailService`. Bedrooms and size range prefer the active PropertySifu raw payload, with official-plan derivation as fallback. Completion details come from catalogue buildings. Stored amenities are EdgeProp-owned. Mapbox may enrich the Overview map visually, but browser Mapbox results never affect demand scores. |
| **Units · Price** | Only `officialFloorPlans(propertysifu)` — PropertySifu's layouts plus the project's admin-owned ones, never the market rows. Cards use the surviving layout's price, bedrooms, bathrooms, car parks and media. The lightweight `/{slug}/unit-rental` endpoint calls the peta-new PropSense whole-unit request for the selected plan using PropertySifu coordinates/location plus its bedroom, sqft and price. |
| **Investment Analysis** | `/{slug}/analysis`, stored per layout under `ProjectDetailController::ANALYSIS_CACHE_VERSION` (`v11` since 2026-09-03; `php artisan layouts:project --slug={slug} --run` recomputes a project's stale layouts). Project Detail always passes `source_separated=true`: active EdgeProp source coordinates within 2 km provide market comparables; new-project analyses keep the peta-new post-2016 filter. **A COMPLETED project is priced against its own market row** when the catalogue holds one (`market_value.basis = own_project`; see [Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md#what-it-does)). Summary/Whole Unit rent is derived from the selected-bedroom EdgeProp rental set shown by the Rental report. **The strip is Summary · Market Value · Rental since 2026-09-03** (founder): the Airbnb Analytics tab and the Rental panel's Room Rental / Airbnb sub-tabs were removed, the components stay in the tree unmounted, and the engine still returns `room_rental` / `airbnb_rental` for the shared Analyze Property pages. **A fourth tab, Airbnb, returned 2026-09-07 on a different data source** — a short-stay operator's actual monthly payouts to owners, not scraped listings — and appears only for a project that has such a ledger; see [Short Stay](/docs/modules_handbook/main/short-stay/readMe.md). The estimate-based `ProjectInvestmentAirbnb*` components stay unmounted and must not be revived inside it. It does not reuse Financial Projection's lightweight rent. |
| **Financial Projection** | Price comes from the selected official PropertySifu plan. Rent follows one deterministic priority: the plan's `rental_price` (peta-new's imported, precomputed PropSense `monthlyRent`) first; the live lightweight `unit-rental` PropSense median when that snapshot is absent; then `price × 0.35%` only after the live request completes without a usable rent. Projection waits for that missing-snapshot request, so tab order cannot change its initial rent. The browser then runs the amortisation/equity model. Do not replace this rule with project-specific hardcoding. |
| **New Supply** | Independent `/{slug}/supply` request. `FindNearbyProjectSupply` returns other published canonical projects with `new_project` membership within 3 km, nearest first, maximum 12. Card prices/unit facts use official PropertySifu plans. The selected floor plan is not an input. |
| **Amenities** | Uses the first analysable official PropertySifu plan as a stable engine anchor, regardless of the selected unit. The provider-separated engine uses the nearest relevant EdgeProp amenity categories. Gemini demand intelligence is cached for seven days by rounded project coordinates under `catalogue-amenity-ai:v3:*`. Mapbox POIs are Overview-only and cannot change BMS, PLI, TOD or buyer-profile results. |
| **Developer Record** | Groups published projects by PropertySifu `developerName`; canonical developer identity is the fallback only when PropertySifu has no developer label. Cards use canonical routes plus official PropertySifu project facts. |
*(There is no **Contact Advisor** tab. It rendered illustrative consultant cards — not real people
and not this project's team — so it promised a named advisor the page could not produce. Removed
2026-09-03; reaching the team is the Payment Plan's WhatsApp button, where a buyer already has a
question in mind.)*

### Units · Price — what the calculator lets a buyer change (2026-09-03)

The reference page priced one fixed scenario. Three things are adjustable here, each a real
Malaysian new-launch mechanic rather than a nicety:

- **The price is editable.** A catalogue price is the LIST price; what a buyer decides on is the
  price after whatever was negotiated. Local to the calculator — nothing else on the page moves,
  and switching unit re-seeds it, because an edit belongs to the layout it was typed against.
- **The loan margin runs 50–100%**, not 0–90%. A rebate package finances effectively the whole
  nett price and a cash-rich buyer wants 50%; a slider capped at 90 could express neither.
- **The legal fee can be waived** (the developer absorbing it is the commonest package, and it is
  worth five figures). It comes out of the upfront cost and so the cash needed, never out of the
  loan, which is drawn on the price alone — `entryFinancing({ legalFeeWaived })`.

### Type scale — Deep Market Analysis (2026-09-07)

`resources/js/Components/ProjectDetail/projectInvestment.css` is the surface all
four Investment Analysis tabs share, and its scale had drifted to a DASHBOARD
one: 10px uppercase labels, 11px table figures, 13px body. Defensible on an admin
screen a colleague reads all day; wrong on a page a **buyer** reads once before
committing seven figures. Reported by the founder as "all font is small,
difficult to read". Raised one coherent step:

| Role | Was | Now |
|---|---|---|
| Uppercase eyebrow labels, footnotes, counts | 10px | **12px** — the floor, and for labels only |
| Secondary lines, table figures, captions | 11px | **13px** |
| Map foot, inputs, market labels | 12px | **14px** |
| Body — table cells, sub-tabs, empty states | 13px | **15px** |
| Card headings | 14px | **16px** |
| KPI figures | 18–24px | **22–29px** |

**Raise the whole scale together, never one selector.** The four tabs sit under
one strip and a reader moving between them must not feel the type change size —
which is also why this landed in the shared file rather than in the panel that
prompted it. Chart axis ticks moved 9→11px and legends 10→12px for the same
reason, with `maxTicksLimit` lowered to match: fewer, readable labels beat more,
unreadable ones. `.ps-project-kpis` drops to two columns under 720px, because
four monospace figures at the raised size no longer fit a phone in one row.

⚠️ **The 90% disclaimer is not a footnote.** At 90%+ the calculator states that the margin assumes a
developer rebate: the bank lends against the SPA price, and on a rebate package the SPA price is
written ~10% ABOVE the nett price so the rebate can be paid out of it. That is why the loan can
cover effectively the whole nett price — and a buyer who does not know it budgets the wrong deposit.

### The saved loan margin — one loan per unit, across every tab (2026-09-07)

The margin, rate and tenure typed on the calculator are **saved** (shared `useLoanInputs` store +
`localStorage['peta:loan-financing']`, this browser, every project) and drive the rest of the page:
the Selected Unit card's instalment, Investment Analysis' Summary payment / cashflow / downpayment
and the Financial Projection's amortisation. There is no Save button — the figures beside the input
already move as you drag, so a button would only ask the reader to confirm what they can see. **The
PRICE is the exception and stays local**: a negotiated price belongs to the layout it was typed
against, a margin belongs to the BUYER and travels with them.

Precedence in `seed()`, each step earned:

1. **An explicit `loanAmount`** — a SAVED record of that property's own loan (Analyze Property's
   `analysis.loan_amount`, persisted per analysis). A record beats a preference.
2. **The member's saved financing**, when they have set one.
3. **The caller's `defaultMarginPct`** — its own convention, used until the member chooses.

That third step is why Deep Market Analysis now passes `defaultMarginPct: 100` instead of
`loanAmount: nextPrice`. Both price at the full price at 4.0% (peta-new parity — adopting the
calculator's 90%/4.2% *by default* made every Summary figure drift), but a fixed `loanAmount` also
made the member's own choice unreachable: the page let a cash buyer set 50% and then quoted an
instalment on 100% two tabs later. A member who never opens the calculator sees exactly what they
saw before — the projection's 90%, the Summary's 100%.

**The unchosen default is one constant**: `DEFAULT_MARGIN_PCT` in
[`utils/analyze/finance.js`](../../../../resources/js/utils/analyze/finance.js) (90). Change it there
and the unit card, calculator, `defaultLoan()` and the projection all follow.

**Payment Plan reaches the team on WhatsApp**, not LINE (`services.propertylab.sales_whatsapp`,
pre-filled with the project and unit). The reference page is Taiwanese; a button to a channel this
market does not use is a dead end wearing a brand colour.

**Save as favourite** lives on the selected-unit card, per LAYOUT — see the Analyze Property
handbook's *shortlist* section for the model, and for the two similarly named props (`savedLayouts`
is the engine cache; `shortlisted` is the member's stars).

### Deep links: `?tab=` and `?unit=`

- `?tab=<key>` opens that tab (`ProjectDetailTabs.applyLocation`, which then
  syncs the `#<tab>` hash). A `#<tab>` hash wins over the query.
- `?unit=<floor plan uuid>` opens the page on THAT layout instead of the
  project's first analysable plan. Layout Analysis lists one row per layout and
  links here, so without it a reader who clicked one row's price and cash flow
  would land on a different unit's numbers. Two halves, and both are needed:
  the page seeds `selectedUuid` from it (ignoring a uuid this project has no
  plan for), and `analysisSeed()` inlines that layout's saved payload instead of
  the anchor's — otherwise the first paint is a loader for numbers we hold.

### Cache and failure rules

- Deep analysis is per canonical project and official floor plan. Changing the
  analysis contract — or the BASIS of a number inside the payload, such as the
  2026-09-03 switch of the headline market PSF from the asking/transacted
  average to the asking median — requires bumping `ANALYSIS_CACHE_VERSION` in
  `ProjectDetailController` (now **v8**), then re-warming: `php artisan
  layouts:project --country=MY --run`. A saved snapshot holds the numbers the
  engine produced under the rule in force when it was written, so skipping the
  bump leaves the same project answering two ways depending on when it was last
  opened.
- The lightweight selected-unit rental is requested by Units, or by Projection
  only when the official plan has no positive `rental_price`. It is cached for
  one hour and includes the PropertySifu location plus plan bedroom/sqft/price
  inputs in its cache key. Projection waits for this request before deciding
  that the final 0.35%-of-price fallback is necessary.
- New Supply is project-level and independently fetched.
- Amenities use a stable project-level plan anchor and location-keyed AI cache.
- **A stored location insight is served only when its `ai_analysis` is usable**
  (`LocationInsightStore::isUsableAiAnalysis()` — an array carrying a numeric
  `t20_pct`; the page mirrors it in `usableAmenityAi()`). The panel prefers the
  AI payload over the engine's income mix for the whole "Who lives here" block,
  and the page treats any non-null payload as "already generated", so a
  placeholder object blanked the T20/M40 donut AND stopped the fetch that would
  have replaced it (Binastra Cochrane, 2026-09-12: a `{"stub": true}` row dated
  2026-08-09, not written by this code path — the only one of 99). Unusable is
  now treated as absent at every step: not served on page load, not persisted,
  not kept in the 7-day Redis key, and an unusable stored row never blocks a
  re-store on a matching input hash.
- A missing provider result renders the documented empty/fallback state. It
  must never trigger fallback to a different provider's similarly named row.
- Cache clearing and tab order must not change which provider owns a fact or
  the Financial Projection seed. Inspect the deterministic priority above
  before changing ingestion or source priority.

## Reference usage

Read the shared page payload:

```php
$detail = app(CatalogueDetailService::class)
    ->forSlug($country, $slug, $user, $presetKey);
```

Read official unit plans:

```php
$plans = $catalogue->officialFloorPlans(DataProvider::CODE_PROPERTYSIFU);
```

Run Project Detail analysis through `ProjectDetailController::analysis()` so
the provider-separated mode, cache contract and official-plan validation are
all applied. Do not call the shared engine directly from a new Project Detail
endpoint.

## Related files

- [app/Http/Controllers/Main/Site/ProjectDetailController.php](/app/Http/Controllers/Main/Site/ProjectDetailController.php)
- [app/Services/Property/CatalogueDetailService.php](/app/Services/Property/CatalogueDetailService.php)
- [src/Analysis/Reference/CatalogProject.php](/src/Analysis/Reference/CatalogProject.php)
- [src/Analysis/Reference/CatalogProjectSource.php](/src/Analysis/Reference/CatalogProjectSource.php)
- [src/Analysis/Services/AnalysisEngineService.php](/src/Analysis/Services/AnalysisEngineService.php)
- [app/Actions/FindProjectDetailWholeUnitRental.php](/app/Actions/FindProjectDetailWholeUnitRental.php)
- [app/Actions/FindNearbyProjectSupply.php](/app/Actions/FindNearbyProjectSupply.php)
- [resources/js/Pages/Main/Site/ProjectDetail.vue](/resources/js/Pages/Main/Site/ProjectDetail.vue)
- [resources/js/Components/ProjectDetail/](/resources/js/Components/ProjectDetail/)
- [routes/main.php](/routes/main.php)
- [docs/property-detail-contract.md](/docs/property-detail-contract.md)
- [Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md)
- [Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md)
