# Project Detail (Main · Public Site)

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

> ⚠️ **This doc describes the MALAYSIA page only.** That one URL serves **three
> separate Inertia pages**, chosen by country in `ProjectDetailController::show()`
> before any of them is built: `Main/Site/ProjectDetail` (MY, via
> `CatalogueDetailService` — this doc), `Main/Site/ProjectDetailHk` (HK, via
> `BuildHkProjectDetail`) and `Main/Site/ProjectDetailAe` (UAE, via
> `BuildAeProjectDetail`). They share the URL and nothing else — different tabs,
> different builders, different component trees. For **HK** read
> [hk-data-requirements.md](/docs/hk-data-requirements.md) +
> [hk-catalogue-mapping.md](/docs/hk-catalogue-mapping.md); for **UAE**,
> [dubai-projects-rollout-plan.md](/docs/planning/dubai-projects-rollout-plan.md).
> Catalogue-wide context:
> [project-catalogue/start-here.md](/docs/modules_handbook/shared/project-catalogue/start-here.md).

## What it does

The Malaysian catalogue project page is one shared page for guests and members. It shows
the project gallery and seven tabs: Overview, Units · Price, Investment
Analysis, Financial Projection, New Supply, Amenities and Developer Record,
plus 360° Aerial when an approved panorama exists. Member-only payloads are omitted server-side for guests and
render through `LockedSection`; there is no second member page.

The panorama is looked up from the page's **already-resolved (federated) project** —
matched on the integer key the bake workflow writes, and additionally on the durable
`catalog_project_uuid` wherever the bake carries one, so a stale integer after a master
re-key cannot put another building's panorama on this page. It must never go back to
`whereHas('catalogProject')`: bakes live in the SITE database, so that `EXISTS`
subquery compiled into the site's own `catalog_projects` — a frozen mirror, or nothing
— instead of the master row the page is showing. A bake written before the uuid
backfill (`catalog_project_uuid` null) is still shown.

The full MY tab surface is also reused by the Area Guide project drawer through
`Components/ProjectDetail/ProjectDetailContent.vue`. The standalone page owns its
Site/Portal shell and title; both consumers receive the canonical
`ProjectDetailController::malaysiaPageProps()` payload. See the
[shared Project Detail content contract](/docs/modules_handbook/shared/project-detail/readMe.md)
for explicit country adapters, drawer URL isolation and request cleanup.

Hosting inside the drawer is a single `inDrawer` prop, and it changes exactly four
things — sticky offsets, project cards opening a new tab, the hero heading level, and
the shortlist star's transport. **Every one defaults to this page's existing
behaviour**, so nothing on the standalone page moved; the shared contract documents
each, in both directions.

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

**Who may see the page at all.** `CatalogueDetailService::viewerMaySee()` is the one
visibility contract, shared by the MY, HK and UAE pages: the public sees a record only
when it is **published AND listed on this site** (`CatalogProject::scopePubliclyListed`
— the same rule as the listing, sitemap and related cards), and a catalogue manager
(`view-projects`) may preview anything, published or not, listed or not, so the review
workflow keeps working on a site that has not selected the project. `forSlug()` returns
`null` when it fails and the page 404s. This used to test publication **alone**, which
meant a site running `Site::MODE_EXPLICIT` still served the detail page for a project it
had deliberately not listed. ⚠️ The converse is the deployment trap: a white-label
switched to explicit mode now 404s **every** project until `site_catalog_projects` rows
exist, and nothing in the UI writes them yet — see the
[Project Catalogue](/docs/modules_handbook/shared/project-catalogue/readMe.md) handbook.

`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 selected-unit card's **Est. monthly rent is the page's one layout rent** (`layoutRent`, below) — the same figure Investment Analysis and Financial Projection show — so opening Units pulls the selected layout's analysis. |
| **Investment Analysis** | `/{slug}/analysis`, a **persisted `UnitAnalysisStore` read-through** keyed by `ProjectDetailController::ANALYSIS_CACHE_VERSION` — not a Redis entry, and not time-limited. (It replaced a six-hour `catalogue-analysis:v7:{project}:{plan}` Redis key, which survived nothing: a `cache:clear` or a Redis restart made the next visitor pay for the engine again, and because every layout missed on its first selection, choosing a different unit showed "PropertyLab AI is analysing…" for a computation containing no AI.) 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. Summary/Whole Unit rent is `petaLayoutRent` over the selected-bedroom EdgeProp rental set shown by the Rental report (dual/triple key: each key's own market, summed; `RentalPrediction::forLayout` mirrors it in PHP). **This figure is the source of `layoutRent`** — it is what Units and Financial Projection quote. Room Rental reads current `catalog_floor_plan_analytics`; Airbnb reads `market_airbnbs`. |
| **Financial Projection** | Price comes from the selected official PropertySifu plan. Rent is **`layoutRent`, one figure for the whole page**, first positive candidate wins: (1) Investment Analysis' predicted rental (`investmentRental`, via `petaLayoutRent`); (2) the plan's stored `rental_price` (peta-new's imported PropSense `monthlyRent` snapshot) when the engine could not price the layout; (3) the live lightweight `unit-rental` PropSense median, requested only after 1 and 2 are both empty; (4) `price × 0.35%`, seeded by `FinancialProjectionTab` and **labelled as an assumption** under the Monthly rent input (`rentSource`). The tab shows a loader until the answer is final — the analysis first, then the fallback — so tab order cannot change its initial rent. Until 2026-09-05 the projection ran (2)→(3)→(4) alone and never saw (1): Residensi Peel's Type D read RM 7,011 on Investment Analysis and RM 3,220 (the 0.35% seed) on Financial Projection. Do not reintroduce a second rent flow. |
| **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 the project's **canonical developer PIVOT** names; the PropertySifu `developerName` label is the fallback, used only when the project has no pivot developer at all. Cards use canonical routes plus official PropertySifu project facts. ⚠️ The order matters and was the other way round until it was found wrong: since the 2026-08-09 name cleanup a PropertySifu row says `SUNWAY Group` where the pivot says `Sunway`, so matching the **label** found nothing and Sunway's page showed an empty track record beside its 92 catalogue records. |
*(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 })`.

⚠️ **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.

**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). On this page the star still posts through
Inertia and the `back()` reload re-seeds the state; the card keeps **one** `SaveLayoutButton` across
unit switches and re-syncs it from the selected layout's uuid, never from `saved` alone — two layouts
commonly share the same value, which is how a star used to carry the previous layout's state onto the
next one. The drawer's JSON transport is documented in the shared contract.

### 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 **v10**; the constant's own docblock records what
  each version changed), 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 the page's rent FALLBACK, and **neither
  tab asks for it unconditionally**. Units and Projection request it only when
  `unitRentFallbackWanted` — a layout is selected, the analysis is no longer
  pending, it produced no investment rent, the plan carries no stored
  `rental_price`, and that tab is not locked. Projection waits for the answer
  before deciding that the final 0.35%-of-price seed is necessary. It is cached for
  one hour and includes the PropertySifu location plus plan bedroom/sqft/price
  inputs in its cache key. On a box without
  `PROPERTYLAB_PROPSENSE_API_KEY` it answers null immediately.
- New Supply is project-level and independently fetched.
- Amenities use a stable project-level plan anchor and location-keyed AI cache.
- 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)
- [Shared Project Detail Content](/docs/modules_handbook/shared/project-detail/readMe.md)
- [resources/js/Components/ProjectDetail/](/resources/js/Components/ProjectDetail/)
- [tests/Feature/Property/ProjectDetailSiteListingTest.php](/tests/Feature/Property/ProjectDetailSiteListingTest.php) — the MY/HK/UAE published-and-listed gate, including a manager's preview.
- [tests/Feature/Main/CatalogueDetailTest.php](/tests/Feature/Main/CatalogueDetailTest.php) — the guest payload, and the pivot-first developer grouping (a project sharing only the PropertySifu **label** under a different pivot must NOT join the track record).
- [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)
