# Shared Project Detail Content

## What it does

`ProjectDetailContent.vue` is the complete Malaysian catalogue detail surface,
shared by the standalone Project Detail page and the Area Guide project drawer.
It renders the same Overview, Units · Price, Investment Analysis, Financial
Projection, New Supply, Amenities and Developer Record tabs in both places,
plus 360° Aerial when an approved catalogue panorama exists. There is no iframe
or second abbreviated set of project tabs.

The provider, membership and analysis rules remain owned by
[Project Detail](/docs/modules_handbook/main/project-detail/readMe.md) and the
[property-detail contract](/docs/property-detail-contract.md). Sharing a visual
component does not grant access to a locked payload.

## How it works

- `ProjectDetailController::malaysiaPageProps()` builds one canonical prop array
  for both consumers. `show()` adds the Inertia page and SEO view data; the Area
  Guide map detail endpoint adds its own access, published-project and host
  country checks before calling that same builder. Existing guest payload
  stripping, shortlist, saved analysis, approved VR, preview and project-view
  recording behavior stay in the canonical path.
- `Pages/Main/Site/ProjectDetail.vue` owns Site/Portal chrome, the document title
  and `?embed=1`. It passes all canonical props to the content component. Normal
  standalone `?unit=`, `?tab=` and `#tab/subtab` deep links retain their behavior.
- `Components/AreaGuide/ProjectDrawer.vue` accepts a selected project and fetches
  `GET /property/academy/area-guide/map/projects/{uuid}`. Its response is
  `{ component: 'my', props: <canonical page props> }`. The drawer renders the
  component adapter matching `component`; `props.countrySlug` must match it.
  The country is explicit and never inferred from the current site's default.
- **A failure is classified, because "Try again" is a lie for most of them.**
  The drawer sorts every rejection into one of three kinds and offers only the
  action that can help:
  - `FAILURE_TRANSIENT` — a network error, a 5xx, anything unrecognised. The
    **only** kind that renders a *Try again* button.
  - `FAILURE_GONE` — 404, a uuid mismatch, a missing `detail_url`. No action is
    offered; retrying would fail identically.
  - `FAILURE_STANDALONE` — HTTP 422 (the country uses its own detail page), an
    unknown adapter, a `countrySlug` mismatch. Renders an *Open project page*
    link in a new tab, built from the 422 body's `href`, falling back to the map
    feed's `project.href`.

  Every candidate link passes a `safePath()` same-origin guard (a leading `/`
  not followed by `/` or `\`), so a `//host/…` value in a response body is
  refused outright and no link is shown rather than an off-site one.
- The drawer is a **real modal dialog**: `role="dialog"` plus `aria-modal="true"`,
  a Tab / Shift+Tab wrap, and a document `focusin` listener that pulls back focus
  landing on the page beneath. The listener is released in `onBeforeUnmount`
  **before** focus returns to the trigger, so Escape-to-close and focus-return are
  unchanged. Layering is DOM order and the panel teleports to `body`: anything
  *contained by or following* the panel counts as a higher layer, so nested
  dialogs (`Modal`, the HeroGallery lightbox) stay reachable and own their own
  Escape.
- The drawer starts on Overview, with its own selected unit. It passes
  `syncLocation=false` through the main tab host, Units sub-tabs and Investment
  sub-tabs. These components neither read nor write the Guide's query/hash in
  this mode. Opening, closing, selecting a unit or changing a detail tab does
  not change the Guide URL or its map camera.
- The drawer also passes `backgroundPrefetch=false`. Overview never initiates
  deep analysis, unit rental or amenity demand. Existing stored analysis may
  arrive in the canonical payload; opening an analytical tab still follows the
  existing lazy analysis endpoints. Optional Overview Mapbox enrichment remains
  the same as the standalone page.
- Each content instance owns an `AbortController` covering its analysis, rental,
  supply, amenity, save and optional Mapbox requests. Unmount aborts them and
  cancels scheduled idle callbacks. Closing a drawer or changing projects also
  aborts its detail request; a late response is ignored. The content is keyed by
  project identity, so another project receives fresh tab/unit/request state.
- On desktop the right-side drawer leaves part of the map available; on mobile
  it uses the full screen. Its own scroll container does not move the map page.
  Closing restores focus to the originating element when that element still
  exists. The optional project-page link opens a separate browser tab.

### `inDrawer` — the four things hosting changes

`ProjectDetailContent` takes `inDrawer` (default `false`) beside `syncLocation`,
`embedded` and `backgroundPrefetch`. It changes exactly four things and nothing
else, so the standalone page's rendering is byte-for-byte what it was:

1. **Sticky strips stick to the host scroller.** `:sticky-under-header="!inDrawer"`
   gives `ProjectDetailTabs` and the Investment sub-tab rail `top-0` instead of
   `top-16`. Inside the drawer the nearest scrolling ancestor is the drawer's own
   `overflow-y-auto` container, so `top-16` left a 64px band that content scrolled
   visibly through.
2. **Developer Record and New Supply cards open a new tab.** Both render as
   `<a target="_blank" rel="noopener noreferrer">` rather than an Inertia `<Link>`,
   so a card cannot replace the host page and lose the Guide's area, map camera and
   the drawer itself. (`NewSupplyPanel`'s unpublished card stays a plain `<article>`
   with no `href`, `target` or `rel`.)
3. **The hero project name is an `<h2>`.** The portal host already prints the page's
   one `<h1>` (GUIDELINES §15). The heading level lives in `ProjectDetailContent`,
   the `#overlay` slot's **owner** — `HeroGallery` only projects the slot, and its
   docblock records that the owner owns the level.
4. **The shortlist star uses the JSON transport** (below).

### The shortlist star inside a drawer

Two halves, and both are load-bearing.

**Server.** `POST /saved-layouts` still toggles one layout and returns `back()` for
every normal or Inertia visit. A caller that asks for JSON (`Accept: application/json`
and **not** an Inertia visit) instead receives `{ "saved": bool }` — the state *after*
the toggle — with no redirect and no flash. The drawer is that caller: its content is a
one-time payload inside another page, so `back()` there would reload the **host**,
never refresh the drawer's shortlist, and leave a toast about a layout the visible
page does not show. See the [Analyze Property](/docs/modules_handbook/main/analyze-property/readMe.md)
handbook for the shortlist model itself.

**Client.** `shortlistedSet` is no longer a computed over `props.shortlisted`. It is a
local `ref`, seeded from the prop, re-seeded by a watch (so the standalone page's
`back()` reload still wins) and updated per layout from each server answer via
`onShortlistChange(uuid, saved)`. In the drawer the prop is a one-time JSON payload
that nothing refreshes, so a derived set went stale the moment a layout was starred —
switching away and back showed the old state, and the next click toggled the server the
opposite way to what the star showed.

⚠️ **`SelectedUnitCard` keeps ONE `SaveLayoutButton` across unit switches, and it must
not be keyed by layout.** The button re-syncs from `floorPlanUuid` (a watch on `saved`
alone cannot see two layouts that happen to share the same value — that was the original
stale star), and *staying mounted* is what lets an answer arriving after the reader
switched unit still reach the shortlist: Vue's `emit()` is a no-op once an instance is
unmounted, so a keyed button silently dropped the server's reply and the star came back
inverted one click later. `update:saved` therefore carries `(saved, uuid)` with the uuid
captured at click time, and the button applies the answer to its own star only while that
layout is still selected. The cost, worth naming: the star is disabled for the selected
layout until the in-flight write settles.

### The drawer's lazy tabs do not rewrite the reader's public site

The project page's lazy endpoints (`analysis`, `supply`, `unit-rental`,
`amenity-demand`, `analysis/save`) sit inside the `public.site` middleware group,
and the drawer calls them from inside the portal. `SetPublicSite` therefore now
persists the year-long `site_country` / `site_lang` / `site_cur` cookies — and the
session `locale` — on **page visits only**: an Inertia visit counts as a page visit,
while anything else that is ajax or asks for JSON is a background call that still
*resolves* its own locale and currency but never writes them back. Without that, a
member whose last public site was `/hk` (zh, HKD) had their language and currency
silently reset to MY defaults just by opening an analytical tab in the drawer. See
the [Markets handbook](/docs/modules_handbook/manage/markets/readMe.md).

### Country scope and boundaries

Only the MY adapter is implemented. HK and AE already have their own complete
country pages, but HK's deferred Inertia groups and AE's automatic analysis
request require explicit adapters before reuse in this drawer. An unsupported
adapter renders an unavailable state and the standalone-page link; it is never
displayed with Malaysian tabs. This shared component does not implement new
uploaded panorama/video authoring or change catalogue VR provenance.

The refusal is explicit and carries a destination. **Every** project outside
`AreaGuideMapController::DRAWER_COUNTRY` (`'MY'`, a named constant rather than a
literal) is answered with **HTTP 422** and a JSON body:

```json
{ "message": "This country uses its standalone project detail page.", "href": "/{iso2 lowercase}/projects/{slug}" }
```

carrying `Cache-Control: private, no-store` like every other map response. `href`
is `null` only when the catalogue row has no slug, and the drawer then falls back
to the map feed's own `project.href`; when neither is a safe same-origin path it
shows the message with no link at all. That is what makes the unsupported state a
hand-off rather than a dead end while the full adapters remain future work.

`useLoanInputs` remains the existing application singleton used by the deep
analysis reports. This drawer is a single-project surface; rendering two
independent analytical detail surfaces simultaneously would require a scoped
loan-input provider before that use case is supported.

### Still open

- **HK and AE adapters are not built.** The frontend adapter map has only `my`
  and the endpoint answers 422 for every other country (with the standalone
  link, above).
- **A dialog opened BEFORE the drawer is unreachable while the drawer is open.**
  Layering is DOM order and the panel teleports to `body`, so a dialog whose
  Teleport container was created earlier counts as *beneath* it and the focus
  listener pulls focus back out of it. On the Manage map this is reachable: that
  page's `openProject` does not clear an open `AssetViewer`, and its form /
  confirm modals mount their Teleport at page load. Those dialogs are also
  *painted* behind the drawer (both `z-50`), so the trap agrees with what the
  reader sees — but an admin who opens the asset form while the drawer is open
  gets a form they can neither see nor type into until they close the drawer.
- **The PHP coverage of the drawer endpoint is partial.** `AreaGuideMapAssetsTest`
  pins the cross-database slug-collision 404, the non-MY 422 body and link, and
  the panorama annotation link. The 200 success shape (`{component:'my', props}`),
  the 403 `AreaGuideViewerAccess` gate and guest/locked payloads are still
  uncovered on this route.
- **Nothing about the drawer has been checked in a browser** — desktop/mobile
  layout, the nested maps, VR, or the sticky strips under a real scrollbar.

## Reference usage

Canonical backend consumer:

```php
$props = app(ProjectDetailController::class)->malaysiaPageProps(
    $request,
    app(CatalogueDetailService::class),
    $country,
    $project->slug,
);

return response()->json(['component' => 'my', 'props' => $props]);
```

Apply the caller's authorization, published/listed-project and host-country
checks before the call. Do not copy or rebuild individual detail props in a map
endpoint. Do not call the analysis engine while preparing Overview.

The visibility half is now enforced on **both** sides, so a future caller cannot
bypass it: `CatalogueDetailService::viewerMaySee()` is the one detail-page contract
— **published AND listed on this site** (`CatalogProject::scopePubliclyListed`, the
same rule as the listing, sitemap and related cards), with a `view-projects`
catalogue manager previewing past both halves — and `forSlug()` returns `null` when
it fails, which `malaysiaPageProps()` turns into a 404. The map endpoint already
resolved a published, site-listed project before calling the builder; nothing
changed for it, but the gate no longer depends on that.

The real frontend consumers are the standalone page and the Area Guide drawer:

```vue
<ProjectDrawer
    :project="{ uuid, name, country: 'my', detail_url, href }"
    @close="selectedProject = null"
/>
```

`uuid` and `detail_url` identify the detail request; `name` labels the drawer,
and `href` optionally links to the standalone project page. The parent retains
ownership of the selected project and map state.

## Related files

- Backend: [ProjectDetailController.php](/app/Http/Controllers/Main/Site/ProjectDetailController.php),
  [CatalogueDetailService.php](/app/Services/Property/CatalogueDetailService.php),
  [routes/main.php](/routes/main.php).
- Backend (shortlist + site preferences): [AnalyzePropertyController.php](/app/Http/Controllers/Main/Portal/AnalyzePropertyController.php) (`toggleSaved`),
  [SetPublicSite.php](/app/Http/Middleware/SetPublicSite.php).
- Shared frontend: [ProjectDetailContent.vue](/resources/js/Components/ProjectDetail/ProjectDetailContent.vue),
  [projectDetailProps.js](/resources/js/Components/ProjectDetail/projectDetailProps.js),
  [ProjectDetailTabs.vue](/resources/js/Components/ProjectDetail/ProjectDetailTabs.vue),
  [ProjectUnitsPriceTab.vue](/resources/js/Components/ProjectDetail/ProjectUnitsPriceTab.vue),
  [ProjectInvestmentAnalysis.vue](/resources/js/Components/ProjectDetail/ProjectInvestmentAnalysis.vue),
  [SelectedUnitCard.vue](/resources/js/Components/ProjectDetail/SelectedUnitCard.vue),
  [SaveLayoutButton.vue](/resources/js/Components/ProjectDetail/SaveLayoutButton.vue),
  [HeroGallery.vue](/resources/js/Components/ProjectDetail/HeroGallery.vue),
  [DeveloperTab.vue](/resources/js/Components/ProjectDetail/DeveloperTab.vue),
  [NewSupplyPanel.vue](/resources/js/Components/ProjectDetail/NewSupplyPanel.vue).
- Consumers: [ProjectDetail.vue](/resources/js/Pages/Main/Site/ProjectDetail.vue),
  [ProjectDrawer.vue](/resources/js/Components/AreaGuide/ProjectDrawer.vue),
  [Area Guide](/docs/modules_handbook/main/area-guide/readMe.md).
- Tests (frontend): [ProjectDetailIsolation.test.js](/resources/js/Components/ProjectDetail/ProjectDetailIsolation.test.js),
  [ProjectDetailDrawerMode.test.js](/resources/js/Components/ProjectDetail/ProjectDetailDrawerMode.test.js),
  [ProjectDrawer.test.js](/resources/js/Components/AreaGuide/ProjectDrawer.test.js),
  [ProjectTabsParity.test.js](/resources/js/Components/ProjectDetail/ProjectTabsParity.test.js),
  [AmenitiesPanel.test.js](/resources/js/Components/ProjectDetail/AmenitiesPanel.test.js).
- Tests (backend): [SavedLayoutToggleTest.php](/tests/Feature/Main/Portal/Analyze/SavedLayoutToggleTest.php) (the JSON second answer),
  [PublicSitePreferenceCookiesTest.php](/tests/Feature/Main/PublicSitePreferenceCookiesTest.php) (page visit vs background call),
  [ProjectDetailSiteListingTest.php](/tests/Feature/Property/ProjectDetailSiteListingTest.php) (the MY/HK/AE listing gate).

Focused tests cover complete tab compilation/parity, preserved standalone deep
links, drawer URL isolation, selected-unit initialization, locked-tab requests,
explicit country, request cancellation, stale responses and project remounting;
`ProjectDetailDrawerMode.test.js` additionally pins each of the four `inDrawer`
behaviours **in both directions** (the standalone default must not move) and the
shortlist star surviving a unit switch mid-flight, and `ProjectDrawer.test.js`
pins the three failure kinds, the hostile-`href` refusal and the focus trap.

They do not replace browser checks of desktop/mobile layout, nested maps, VR,
scrolling and real authorized project data. **No browser, production, real-asset
or physical-device acceptance has been run for any of the above.**
