# Dubai (AE) Projects Rollout — Implementation Plan

**Date:** 2026-08-19 · **Branch:** SOL-FLG-module · **Planned by:** Fable 5 (planning session) · **For:** Opus 5 implementation session

---

## 0. Goal

Add **Dubai / UAE (`AE`)** as the third public market in petav3, after MY and HK:

1. **Import** all UAE projects from the peta-new prod reference DB (`REFERENCE_DB_*` → `peta_wk_dev` @ 159.65.139.125) into the **local** catalogue tables, catered to petav3's schema.
2. Once verified locally, **migrate** the AE catalogue rows (projects, floor plans, media, sources) to the **master DB** (`MASTER_DB_*` → `master_projects` @ 34.87.149.195).
3. Build the **public-facing side**: `/ae/new-projects` list page and `/ae/projects/{slug}` detail page with all tabs, matching **peta-new's UAE portal UI, features, data, amounts, prices and analysis results EXACTLY**.

**THE ONE RULE (from `docs/hk-handoff.md`, learned the hard way): port the business logic VERBATIM.** Same constants, same query shapes, same filters, same source columns, same order of operations. Every HK defect came from a small re-derivation that produced a plausible-but-wrong number instead of an error. Where the reference reads a RAW value, store the raw value — never substitute a petav3-derived equivalent.

Reference implementation (peta-new = `/Users/yongzhi/laravel-projects/peta-new`):

| Concern | Reference file |
|---|---|
| UAE analysis engine (RapidAPI client) | `app/Services/InvestHink/EstateUaeService.php` (745 lines) |
| Public list controller | `app/Http/Controllers/PublicSite/NewProjectsController.php` — `index()`/`listing()`/`allCards()` (UAE branch), `show()` → `renderUaePortalDetail()` (line ~2205), `uaeAnalyze()` |
| Detail page (UI + all client formulas) | `resources/views/investhink/uae-portal/project.blade.php` (1,119 lines, Alpine `projectPage()`) |
| List page UI | `resources/views/public/new-projects/index.blade.php` + `_pager.blade.php` |
| Import commands | `app/Console/Commands/UaeImportProjects.php`, `UaeGeocodeProjects.php`, `UaeBuildLayouts.php` |
| Models | `src/InvestHink/UaeProject.php`, `src/InvestHink/UaeLayout.php` |
| Layout analysis API | `PortalController@apiUaeLayouts` (vsMarket calc) |
| Currency | `app/Support/Currency.php` (`RATES['AED'] = 0.272`, `SYMBOL['AED'] = 'AED'`) |

---

## 1. Source data inventory (verified against the live reference DB, 2026-08-19)

### 1.1 `ih_uae_projects` — 951 rows

| slice | count |
|---|---:|
| total | 951 |
| `isActive = 1` | **3** (curated for the member portal — see quirk below) |
| with `imageUrl` | 951 (all) |
| with `priceFrom` | 849 (846 inactive + 3 active) |
| with `lat`/`lng` | 951 |
| with `raw` JSON | 951 |
| city = Dubai / Abu Dhabi / Ras Al Khaimah / Ajman / Umm Al Quwain | 786 / 99 / 64 / 1 / 1 |
| freshness | createdAt from 2026-07-03, **max updatedAt 2026-07-09** |

Columns: `id, externalId(64), slug(191, unique), name, developerName, developerLogo, community, city, fullLocation, lat DECIMAL(10,7), lng DECIMAL(10,7), priceFrom BIGINT (AED), downPaymentPct TINYINT, bedrooms JSON (ints, 0 = Studio), deliveryDate DATE, imageUrl, description TEXT, hotness INT, ownershipType(50) ('freehold'|'leasehold'), isActive, isManual, raw JSON, createdAt, updatedAt` (non-standard timestamp names).

**`raw` JSON is load-bearing.** Keys observed: `id (PropertyFinder uuid), name, slug, price {from, down_payment_percentage}, images[] {small, medium, mobile}, bedrooms[], location {tree[], full_name}, max_size {unit:'sqm', value}, min_size {unit:'sqm', value}, developer {id, logo, name}, is_sponsored, campaign_type, delivery_date, hotness_level, payment_plans[] {title, summary {handover, down_payment, after_handover, during_construction}}, property_type, contact_options[] {type, link, value}, effective_price, sales_phase_key, sales_start_date, stock_availability, construction_phase_key, construction_progress, last_inspection_date`. Media URLs are hot-links to `new-projects-media.propertyfinder.com`.

**The `isActive` quirk (do not "fix" it):** peta-new's bulk import deliberately creates rows with `isActive = false`; only 3 hand-curated rows are active. The PUBLIC list does **not** filter on `isActive` for UAE — it uses `whereNotNull('imageUrl')`. Only the member-portal curated list uses `isActive = true`. Parity therefore means **publishing all 951**, not 3.

### 1.2 `ih_uae_layouts` — 2,805 rows (precomputed per-bedroom analysis, built by `uae:build-layouts`)

Columns: `projectSlug (idx), projectName, developerName, community, city, bedrooms TINYINT (0 = studio), sizeSqft INT, price BIGINT (AED), projectPsf INT, marketPsf INT (nearby actual sold, DLD), forecastRent BIGINT (**AED per YEAR**), roi DECIMAL(5,2) %, marketValue BIGINT, floorPlan TEXT (single image URL), hotness INT, timestamps`. Non-unique index `(projectSlug, bedrooms)` — multiple rows per bed count are possible; import them all.

Coverage: floorPlan 1,800 · roi 886 · forecastRent 1,446 · marketPsf 2,152. City split: Dubai 2,239 (685 slugs) · Abu Dhabi 364 (85) · RAK 198 (52) · Ajman 3 · UAQ 1.

### 1.3 What is NOT in the reference DB (fetched live from RapidAPI by peta-new)

Full gallery + videos + master plan, unit types with per-layout floor plans, **payment-plan milestone values**, **DLD recent transactions**, **community insights (rents by bedroom)**, **price trends (1Y/5Y growth)**, amenities list, nearby-supply projects, construction timeline, `government_fees`. peta-new calls `estate-real-time-uae.p.rapidapi.com` on every page view with a 30-minute cache. See §4 for how we handle this.

---

## 2. Target architecture decisions

### D1 — Dubai follows the **HK pattern**, not the MY engine
`config/markets.php` has only `MY`; `MarketProfile::resolve('AE')` throws `UnknownMarketException` by design. HK never got a profile — its detail page is a parallel `BuildHk*` action family. Dubai's analysis is a completely different engine (RapidAPI-derived community model), so: **build a `BuildAe*` action family + `EstateUaeService` port; do NOT add an `AE` profile to `markets.php`** and do not route AE through `AnalysisEngineService`. Guard every shared code path so AE never reaches `MarketProfile::resolve()`.

### D2 — Data-first, API-snapshot detail page (recommended)
peta-new computes the detail page live from RapidAPI. Exact parity requires the same input data, so we have two options:

- **(a) RECOMMENDED — harvest & persist:** a `catalogue:harvest-uae-details` command fetches every RapidAPI payload once per project (details, transactions, community insights, price trends) and persists them into `catalog_project_sources` scoped rows (the exact pattern HK used for Centanet valuations: raw payloads ride in source `fields`/`raw_payload`, no new tables). The page then computes **deterministically from the snapshot** with verbatim-ported formulas. Survives without the API at request time, shareable via master DB, re-runnable to refresh.
- (b) Live proxy: port `EstateUaeService` as-is with the 30-min cache and call RapidAPI per request. Matches peta-new's *behaviour* exactly but costs API quota per view and the master DB carries no detail data.

Plan assumes **(a)**, with the service written so the fetch layer is swappable (harvest command and any future live mode share the same client + parsers). **Prerequisite: `ESTATE_UAE_RAPIDAPI_KEY`** — it is NOT in either local `.env`; get it from the peta-new **prod** `.env` (config key `services.estate_uae.host/key`).

### D3 — Provider = `propertyfinder`, connection = `reference`
New entry in `config/project_catalogue.php → providers` + `data_providers` row, adapter `PropertyFinderUaeAdapter` modeled on `PropertySifuMyAdapter` (which also joins a projects table with a layouts table keyed by slug) and `HkReferenceAdapter` (connection/watermark/decode plumbing).

### D4 — Slug flattening (routing constraint)
Reference slugs contain a slash (`sol-properties/fairmont-residences-solara-tower`); petav3's route is `GET /{country}/projects/{slug}` with a single segment. **Public slug = `str_replace('/', '-', $slug)`** (deterministic, e.g. `sol-properties-fairmont-residences-solara-tower`). The ORIGINAL slug is preserved as `catalog_project_sources.external_id` (it is also the RapidAPI details-endpoint key, so it must survive verbatim). Verify uniqueness after flattening (951 slugs; collisions are unlikely but assert zero in the adapter).

### D5 — Media: ingest as URLs, then store to GCS
Ingestion writes `catalog_media` rows carrying the provider `url` with `media_id = NULL` (standard pattern). Then reuse `CatalogueMediaStorageService` via a generalized `catalogue:store-uae-media` command (clone of `StoreHouse730CatalogueMedia`) to download hero/gallery/floor-plan images into GCS + `MasterMedia`, flipping `url → media_id`. peta-new hot-links propertyfinder.com — we should NOT rely on that long-term (hot-link rot + the user explicitly wants "medias and all" migrated). The page renders either state (media_id preferred, url fallback), so this phase can trail the UI build.

### D6 — Language / copy
peta-new's UAE page is authored in **Traditional Chinese for Taiwanese buyers** (NT$ conversions, LINE contact, 台灣人專屬顧問). petav3's public site supports `en, ms, zh_CN` with English-as-key `t()`. Follow the HK precedent: author components with the reference's visible strings as translation keys/entries (`resources/lang/zh_CN.json`), English fallback. **Open decisions for the user (do not block Phase 1–4 on these):**
- Keep the NT$ (TWD @ hardcoded 8.6) secondary price display verbatim, or drop it in favour of the site currency switcher (AED already fully supported in `App\Support\Currency`)? *Verbatim-parity default: keep it.*
- Keep the hardcoded Taiwan advisor cards + LINE CTA on the contact tab, or swap to petav3's own advisor/contact pattern? *Default: port the tab structure, swap contact identities — flag to user.*

### D7 — Publication must survive a master re-seed
Master refresh runbook (§12.6 of `docs/planning/master-catalogue-shared-database-plan.md`): `published_at` does not survive a re-seed; HK re-publishes via `catalogue:hk-publish-parity`. Dubai needs its own **`catalogue:ae-publish-parity`**: publish every AE project with an active `propertyfinder` source that has a hero image (mirror of peta-new's `whereNotNull('imageUrl')`). Document it in the refresh runbook.

---

## 3. Phase 0 — Market foundation (small, unblocks everything)

1. **Countries row** — data-only migration mirroring `2026_07_12_100007_add_hong_kong_to_countries.php` (idempotent `if exists → return`):
   `iso2 AE · name United Arab Emirates · currency_code AED · currency_symbol AED · locale en_AE · timezone Asia/Dubai · is_active 1 · is_default 0`.
2. **Market-site visibility** — `market_site_countries` row linking country AE to site 3 (`propertylabglobal.com`, which is `all_markets = 0`; site 1 default/all_markets picks AE up free). Same migration or the Manage → Setting → Markets UI; prefer the migration for reproducibility. Remember `MarketSiteResolver::touch()` runs via the repository — a raw migration insert must bust the cache (call `touch()` in the migration or note `cache:clear`).
3. **Locale preset** — `App\Support\LocalePreset::PRESETS['ae'] = ['label' => 'English', 'flag' => 'ae', 'lang' => 'en', 'cur' => 'AED', 'country' => 'United Arab Emirates']` + matching key in `config/project_catalogue.php → locale_presets`.
4. **Currency** — nothing to do: `App\Support\Currency` already has AED in `RATES` (0.272), `SYMBOL`, `OPTIONS` — same value as peta-new.
5. **Completeness contract** — `config/project_catalogue.php → detail_contract.waived_by_country['AE']` for whatever the feed genuinely lacks (at minimum likely `completion` for rows without deliveryDate; decide from real data during Phase 1, not upfront).
6. **Home globe fix** — `resources/js/Pages/Main/Site/Home.vue:384` checks `c.market === 'uae'` while the city entries use `market: 'ae'` — fix to `'ae'`. The Dubai + Abu Dhabi globe entries then light up automatically once AE is active on the host.
7. Verify: `/ae/home` renders, country switcher shows UAE, AED currency selected by default, `region/ae` preset works.

---

## 4. Phase 1 — Ingestion from the reference DB (local first)

`CATALOGUE_USE_DEFAULT_CONNECTION=true` locally, so the `catalogue` connection collapses onto the local `petav3` DB — all ingestion lands locally exactly as HK's did. **No new tables and no schema migrations are expected** — everything maps onto the existing `catalog_*` family; raw reference fields that have no column ride in `catalog_project_sources.fields` / `amenities` JSON (HK precedent).

### 4.1 Registry

- `data_providers`: new row `code = propertyfinder` (next free id locally — **but see §6.2 for the master-side id contract**).
- `config/project_catalogue.php → providers['propertyfinder'] = ['country' => 'AE', 'adapter' => PropertyFinderUaeAdapter::class, 'connection' => 'reference']`.

### 4.2 `PropertyFinderUaeAdapter` — field mapping (verbatim contract)

Reads `ih_uae_projects` (ALL 951 rows — no `isActive` filter, see §1.1 quirk) + `ih_uae_layouts` grouped by `projectSlug`. Watermark = `max(updatedAt)` across both tables (reuse `HkReferenceAdapter`'s `tableWatermark()`/sentinel/decode plumbing via extension or a sibling base).

**→ `catalog_projects`:**

| reference | catalog_projects | note |
|---|---|---|
| `slug` | `slug` | flattened per D4 |
| `name` | `project_name` | |
| `description` | `description` | raw English; AI translations are separate (§5.6) |
| `developerName` | `developer` | RAW label, never resolved through the developer pivot (HK lesson) |
| `community` | `area` | |
| `city` | `state` | the emirate; NULL → default `'Dubai'` at DISPLAY time like peta-new (`$p->city ?: 'Dubai'`), store as-is |
| `fullLocation` | `address` | |
| `lat` / `lng` | `latitude` / `longitude` | |
| `priceFrom` | `price_min` | AED integer; `price_max` stays NULL (peta-new has no priceTo for UAE) |
| `deliveryDate` | `completion_date` + `completion_year` | |
| `ownershipType` | `tenure` | store raw (`freehold`/`leasehold`); display map 永久產權/租賃產權 lives in the frontend |
| — | `market_segments` | `['new_project']` |
| — | `country_id` | AE row id |

**→ source-level raw fields** (`catalog_project_sources.fields`, provider `propertyfinder`, `external_id` = ORIGINAL slug, `external_asset_id` = `externalId`, `raw_payload` = full row incl. `raw` JSON): `hotness` (list sort key — there is no catalog column), `downPaymentPct`, `bedrooms` (array, 0 = Studio), `developerLogo`, `isActive`, `isManual`, `ownershipType`, and from `raw`: `construction_phase_key`, `construction_progress`, `min_size` / `max_size` (**sqm — store raw, convert ×10.7639 at read like peta-new**), `property_type`, `sales_phase_key`, `sales_start_date`, `stock_availability`, `payment_plans`, `contact_options`, `effective_price`, `hotness_level`.

**→ `catalog_floor_plans`** (one per `ih_uae_layouts` row; identity key from slug+bedrooms+ordinal so re-runs upsert):

| reference | catalog_floor_plans |
|---|---|
| `bedrooms` | `bedrooms` (0 = studio; name/label "Studio" vs "{n}BR" derived at display) |
| `sizeSqft` | `sqft` |
| `price` | `price` |
| `projectPsf` | `asking_psf` |
| `marketValue` | `market_value` |

**→ floor-plan source `fields`** (`catalog_floor_plan_sources`): `marketPsf`, `forecastRent` (**AED per YEAR — never divide to monthly**), `roi`, `hotness`. Do NOT map `forecastRent` into `rental_price` (that column is monthly-rent semantics for MY) — the RAW value in fields is what the UI reads.

**→ `catalog_media`** (url rows, `media_id = NULL`): `imageUrl` → kind `hero`; `raw.images[]` (prefer `medium`, fallback `small`, positions in array order) → kind `gallery`; `ih_uae_layouts.floorPlan` → kind `floor_plan` attached to the floor plan; `developerLogo` → keep in fields only (no media kind for it today; the UI reads it from fields).

### 4.3 Run + verify

```bash
PHP=/opt/homebrew/opt/php@8.4/bin/php
$PHP artisan catalogue:sync propertyfinder --full     # persistent shell — harness kills long runs
```

Verify counts: 951 projects (state split 786/99/64/1/1), 2,805 floor plans, media rows = 951 hero + Σ raw.images + 1,800 floor-plan URLs. Spot-check `sol-properties-fairmont-residences-solara-tower` field-by-field against the reference row. `catalogue:completeness --country=AE` to size the waiver list for Phase 0.5.

---

## 5. Phase 2 — Detail-data harvest (RapidAPI snapshots) + AI translations

### 5.1 `EstateUaeClient` (port of `EstateUaeService`'s HTTP layer)

Config `services.estate_uae` = `['host' => env('ESTATE_UAE_RAPIDAPI_HOST', 'estate-real-time-uae.p.rapidapi.com'), 'key' => env('ESTATE_UAE_RAPIDAPI_KEY', '')]`. Endpoints: `/new-projects/details`, `/new-projects/search-by-locations`, `/new-projects/auto-complete`, `/transactions/search`, `/properties/details-community-insights`, `/properties/details-price-trends`, `/properties/auto-complete`. 30 s timeout, `x-rapidapi-host`/`x-rapidapi-key` headers, try/catch, cache key `estate_uae:` + md5(url) (30 min) for interactive use.

### 5.2 `catalogue:harvest-uae-details` command

Per AE project (resumable, `--project=`, `--limit=`, throttled ~`usleep(120000)` like `uae:build-layouts`), fetch and persist as **scoped source rows** on the existing `catalog_project_sources` table (provider `propertyfinder`, same `external_id`, distinct `source_scope` values — mirrors HK's `centanet-estates` scope trick, no migrations):

| `source_scope` | payload | feeds |
|---|---|---|
| `uae-details` | `/new-projects/details` (original slug) | gallery/videos/master plan, unit types + layouts, payment plans, construction timeline, amenities, `government_fees`, developer, contact options, stock availability |
| `uae-transactions` | `/transactions/search` for the community (per relevant bed counts) | 周邊真實成交 DLD table, marketPsf mean |
| `uae-insights` | `/properties/details-community-insights` | `rental_by_bed`, rent averages |
| `uae-trends` | `/properties/details-price-trends` | 1Y/5Y growth, current_psf |

Store the RAW API JSON in `raw_payload` (+ `data_scraped_at`). All parsing/derivation happens at read time in `BuildAe*` so a formula fix never requires a re-harvest.

### 5.3 Parser/analysis layer — `App\Actions\BuildAe*` family (port VERBATIM from `EstateUaeService`)

- `BuildAeProjectDetail` — the page payload (analog of `BuildHkProjectDetail`): hero, key facts, unit types, payment plan, timeline, amenities, supply, developer record, plus the per-bed analyze payload below.
- `BuildAeCommunityAnalysis` — port of `analyze($area, $beds)`:
  - comparables filter: size bands per bedroom `[0=>[280,750], 1=>[450,1200], 2=>[750,1900], 3=>[1300,3200], 4=>[1900,6500], 5=>[3000,12000]]`, fallback `[200, 20000]`; PSF ∈ `[300, 12000]`; `price > 0`.
  - `median()`: drop ≤0, sort, odd→middle, even→`round(avg of two middles)`. `percentile()`: linear interpolation on `idx = p/100 × (n-1)`, `round()`ed.
  - `medianPsf = median(psf)`, `typicalSize = median(size)`, **`predicted_value = round(medianPsf × typicalSize)`**; `value_low/high = round(percentile(psf, 25/75) × typicalSize)` only when `count ≥ 4`.
  - rent: community insights `price_transactions` where `property_type_id == 1 && offering_type_id == 2` → `rentAvg` for selected bed; `rental_by_bed` for all beds (ksort).
  - **`gross_yield = round(rentAvg / predicted_value × 100, 1)`**; `market_psf = medianPsf ?: summary.sale_avg_price_per_sqft`; `yoy_psf_change = round(summary.sale_avg_price_per_sqft_change, 1)`; `market_roi = round(summary.roi, 1)`.
  - growth: per `1Y`/`5Y` series `pct = round((last-first)/first × 100, 1)`; `current_psf` = last of 1Y (fallback 5Y).
  - `comparables` = 12 most recent; `similar_communities` = 6.
- `BuildAeProjectAnalysis` — port of `analyzeProject($slug, $beds)`:
  - community from `location.full_name` split: `parts[0]` city, `parts[1]` community.
  - media: `gallery[].variants.big` fallback `src`; video if `category === 'video'` or `.mp4|.mov|.webm|.m3u8`; `master_plan[0]` appended; photos cap 20, media cap 24.
  - layouts: `units.unassigned.per_property_type[].per_bed[].layouts[]`, `size_sqft = round(size × 10.7639)`.
  - unit-type **size-sanity re-multiply hack** (yes, verbatim): floor `= bed === 0 ? 180 : 300 + bed×150`; if sqft < floor → multiply by 10.7639 again. Missing sizes backfilled from median transaction size for the same bed (`size_estimated = true`).
  - recent transactions: community search (has building names) preferred, fallback `transaction_history.transactions` (sqm→sqft when `unit === 'sqm'`); `psf` = API value else `round(price/sizeSqft)`; date desc, **slice 20**.
  - payment plan `payment_plans[0].Values[] → {title, percent: progress_percentage, stone: mile_stone}`; construction timeline from `phases_timeline_per_category.construction[]`.
  - extras: `government_fees, construction_completed_date, number_of_buildings, sales_start_date, has_payment_plan, stock_availability, ownership_type, starting_price, developer{name,logo,slug}, contact_options`.
  - nearby supply: `newProjectsByLocation(community)`, exclude self, cap 8.
- `BuildAeDeveloperRecord` — from OUR catalogue (peta-new reads its own DB here, `/uae-portal/api/developer`): completed vs ongoing counts + project cards by raw `developerName`.

No classes get private/protected methods (user rule); follow the HK actions' shapes.

### 5.4 Anonymous stripping (exact)

`uaeAnalyze` parity: for guests `unset($result['market'], $result['recent_transactions'], $result['supply']); unset($result['project']['unit_types'], $result['project']['payment']);` and tabs `units` / `invest` / `supply` render the login-lock partial.

### 5.5 Layout precompute parity check

`ih_uae_layouts` was built by `uae:build-layouts` with: `projectPsf = round(price/size)`, `roi = round(rent/price × 100, 2)` (rent = `rental_by_bed[bed]`), `marketValue = round(marketPsf × size)` where `marketPsf = round(market.prediction.market_psf)` anchored on `repBed()` = median of the project's bedrooms array (else 1). We **import** these rows rather than recompute (verbatim rule) — but keep the formulas documented here since `vsMarketPct = round((price - marketValue)/marketValue × 100)` and `vsMarketAbs = price - marketValue` are computed at read time in the layout-analysis API.

### 5.6 AI translations (社區介紹 + 關於此項目)

peta-new runtime-translates the English description via Gemini (`translateDescription`, temp 0.4, maxOutputTokens 8000 / 1200 brief, input clipped 6,000 chars, cached 30 days under `uae_desc_zh:v3:md5` / `uae_city_zh:v3:md5`). In petav3, route this through the shared **`AiClient`** (per `docs/modules_handbook/shared/ai/readMe.md` — read its Reference usage first) with the same prompts, and **persist** results into `catalog_ai_contents` (kind e.g. `uae_description` / `uae_community_brief`, locale, `input_hash`) via a harvest step, so the page never blocks on a live AI call. Note: Chinese target variant follows D6's language decision.

---

## 6. Phase 3 — Media storage + Phase 4 — Master DB migration

### 6.1 Media → GCS

`catalogue:store-uae-media [--project=slug] [--kind=hero|gallery|floor_plan] [--limit=] [--dry-run]` — clone `StoreHouse730CatalogueMedia` → `CatalogueMediaStorageService` (download → `MediaService::put(..., 'catalogue/'.$project->uuid, ...)` → `MasterMedia` row → inside `DB::connection('catalogue')->transaction()` + `lockForUpdate()`: `url = null, media_id = id`). Idempotent (skips `media_id !== null`). propertyfinder serves `.webp`/`.png` — confirm the fetcher accepts those content types. Run AFTER local data is verified correct (files are the expensive, slow part) — long run, persistent shell.

### 6.2 Local → master migration

Follow the uuid-keyed portable package (this is literally what it was built for — `catalogue:export` has a `--country` filter):

```bash
$PHP artisan catalogue:export /path/ae-catalogue.json --country=AE      # SCHEMA_VERSION 4
# then against master (env flip or a one-off env override so the catalogue connection = MASTER_DB_*):
$PHP artisan catalogue:import /path/ae-catalogue.json --dry-run
$PHP artisan catalogue:import /path/ae-catalogue.json
```

Pre-flight on master (all verify steps, don't assume):
1. `countries` row `AE` exists on master (the seed script copies `countries` into master; if master predates this row, insert it — **same iso2; integer id may differ, the import must resolve by iso2/uuid, verify how `ImportProjectCatalogue` resolves country and provider FKs before running**).
2. `data_providers` row `propertyfinder` exists on master **before** import (verify how the importer resolves provider — by code or id — and align).
3. `media` / `MasterMedia` rows travel with the package (media stored in §6.1 must be included; confirm export schema v4 covers `media` table rows or run the storage step against master after import).
4. Dry-run first; expect 951 projects / 2,805 plans; run `catalogue:repair-refs` style verification after.
5. **Coordinate with Wai Kit** (master is populated by his scraper pipeline per the §10 owner amendment; every platform is a read-only spoke): the AE rows enter master through this one-time import, and his refresh contract — *upsert by (provider, external_id), preserve uuids, whole-family copy* — must now include the `propertyfinder` provider or his next refresh could drop/duplicate AE. This is a conversation, not just a script run.
6. After cutover, prod site reads AE via the `catalogue` connection exactly like MY/HK (federation dedupes by uuid; AE rows are master-origin).
7. Update the refresh runbook (§12.6 of the master plan doc): add `catalogue:ae-publish-parity` to the post-refresh steps.

---

## 7. Phase 5 — Public list page (`/ae/new-projects`)

Extend the existing shared machinery (`SiteController@newProjects` → `App\Actions\BuildNewProjectListing` → `Pages/Main/Site/NewProjects.vue`) with an AE branch. Parity contract from peta-new's `allCards()` UAE branch + `index.blade.php`:

- **Row source:** published AE projects (publication = `catalogue:ae-publish-parity`, mirror of `whereNotNull('imageUrl')` — §D7).
- **Ordering: `hotness DESC, name ASC`** — hotness comes from the `propertyfinder` source `fields` (no catalog column; use a correlated subquery or hydrate-then-sort inside the builder — NEVER a join that inflates the paginator, per GUIDELINES §14). Note: HK's list still can't reproduce hotness (`docs/hk-handoff.md` known gap) — AE CAN, because hotness is in the reference data; map it through.
- **Card fields:** image (hero), market ribbon **UAE = amber** (`bg-amber-500/90`; other markets emerald), location (`community`), name, "by {developer}", "From {AED price}" via `Currency::fmt` (no priceTo), beds pill with **0 → "Studio"**, lat/lng for the map.
- **Filters:** free-text `q` (name + developer + location), **`city` chips = emirate with counts** (Dubai 786 / Abu Dhabi 99 / RAK 64 / Ajman 1 / UAQ 1), `view=map|table`, page size 24. `yr`/`avail`/`disc` filters are HK-only — AE cards must not react to them.
- **Map view:** Mapbox pins for ALL filtered results (not just the page) — petav3 already has this for the list; confirm AE pins carry through.
- **SEO:** market label "Dubai & UAE"; canonical/prev/next; sitemap picks AE up automatically (`SitemapController` iterates active countries + published rows).
- Nav: `SiteLayout.vue` — decide whether AE needs a nav affordance like HK's "HK Guide 3D" (default: no; flag to user).

---

## 8. Phase 6 — Detail page (`/ae/projects/{slug}`)

### 8.1 Controller branch

`ProjectDetailController`: add `private const UNITED_ARAB_EMIRATES = 'AE'` beside `HONG_KONG`; branch in `show()` → `showUae()` rendering `Main/Site/ProjectDetailAe` (third render path, exactly how HK is a second one). Slow payloads via `Inertia::defer()` groups like HK. New route `POST /{country}/projects/{slug}/uae-analyze` (name `main.site.projects.uae-analyze`) → `@uaeAnalyze` with `abort_unless($countryRow->iso2 === self::UNITED_ARAB_EMIRATES, 404)` — accepts `{beds}`, returns the `BuildAeProjectAnalysis` payload, anonymous-stripped per §5.4. Bedroom switch re-POSTs, exactly like peta-new's Alpine page.

### 8.2 Page + components

`resources/js/Pages/Main/Site/ProjectDetailAe.vue` + `resources/js/Components/ProjectDetailAe/*` (mirroring the HK component split). Structure from `uae-portal/project.blade.php`:

**Hero:** gallery (images + videos + master plan), thumbnail strip, lightbox, price/CTA overlay, phase badge (未開工/興建中/已完工 from `construction_phase_key`), stock badge (即將售罄/已售罄), JSON-LD `Product` with `priceCurrency: 'AED'`, meta description `"{name} by {developer} — new launch in {community}, {city}. From AED {price}."`.

**Seven tabs, this exact set and order** (`$uaeTabs`):

| key | label | contents | locked (guest) |
|---|---|---|---|
| `overview` | 概覽 | key-facts bar (產權形式 · 預計交屋 · **政府過戶費 4% DLD** · 房型), construction-progress timeline, 社區介紹 (AI brief), 關於此項目 (AI translation) | no |
| `units` | 戶型價格 | bed-type nav + unit cards (beds, sqft + 坪, AED + NT$ 萬, market rent/yr, stock badge, floor-plan thumb → lightbox) + **付款計畫** stacked % bar with per-milestone AED | **yes** |
| `invest` | 投資分析 | 投資試算 card, 全成本淨現金流試算 (mortgage calculator), PSF vs market comparison, 周邊真實成交 DLD table | **yes** |
| `amenity` | 周邊設施 | API amenities w/ icons + Mapbox 3D map (`pitch 50`, style `mapbox://styles/mapbox/standard`) + Tilequery POI list (radius 3000 m, limit 50, dedupe, layer `poi_label`, top 12 by distance) | no |
| `supply` | 新供給 | up to 8 nearby new-project cards | **yes** |
| `developer` | 開發商 | completed vs ongoing counts + project cards from OUR catalogue | no |
| `contact` | 聯絡顧問 | advisor cards (see D6 open decision) | no |

Persistent **bedroom selector** bar under the tabs when `tab ∈ {units, invest}`. Default bed on load: `?bed=N` if valid → else **cheapest priced unit type** → else lowest bed count; initial `beds = 2` pre-load.

### 8.3 Client-side formulas — copy TO THE DIGIT

Component defaults: `AED_TWD: 8.6, ncDp: 20, ncMortgage: false, ncLtv: 50, ncRate: 4.5, ncYears: 25, ncScPsf: 15, ncAgent: true`. Sliders: LTV 0–80 step 5; service charge 0–40 AED/sqft/yr step 1; rate & years free numeric.

**`calc` (投資試算):**
```js
price = selUnit.price_from; size = selUnit.size_from_sqft
govPct   = Number(project.government_fees) || 4        // DLD default 4%
govFee   = round(price * govPct / 100)
totalCost = price + govFee
rent     = market.prediction.predicted_rent            // AED/year
yieldOnCost = rent / price * 100                       // divides by PRICE, not totalCost — keep the asymmetry
payback   = price / rent
marketPsf = round(mean(recent_transactions[].psf > 0)) // arithmetic MEAN of ≤20 tx
projectPsf = round(price / size)
psfDelta  = (projectPsf - marketPsf) / marketPsf * 100
```

**`netCalc` (全成本淨現金流試算):**
```js
rent  = rentForBed(selUnit.bedrooms) || calc.rent      // rental_by_bed[bed], rounded
govFee = round(price * 0.04)                           // HARD-CODED 4% here (ignores government_fees) — deliberate reference asymmetry, keep it
agentFee = ncAgent ? round(price * 0.02) : 0
upfront = price + govFee + agentFee
loan = ncMortgage ? round(price * ncLtv/100) : 0
cashNeeded = max(0, upfront - loan)
r = ncRate/100/12; n = ncYears*12
monthlyMortgage = r>0 ? loan*r*(1+r)^n / ((1+r)^n - 1) : loan/n   // then Math.round()
sc = round(size * ncScPsf)                             // AED/yr
netRent = max(0, rent - sc)
netYield = netRent / upfront * 100                     // on TOTAL upfront
monthlyCf = round(netRent/12 - monthlyMortgage)
payback = cashNeeded / netRent
```

**Formatters:** `fmtAed → 'AED ' + en-US toLocaleString (0 dp)` · `twdWan → 'NT$' + round(aed*8.6/10000).toLocaleString() + ' 萬'` · `twdWanFull → 'NT$' + round(aed*8.6).toLocaleString()` · `ping → round(sqft / 35.583)` · size label `'{sqft} sqft · 約 {ping} 坪'`.

**Payment plan:** milestone AED = `round(percent/100 × calc.price)`; bar colors cycle `['#2563EB','#4F46E5','#0EA5E9','#8B5CF6','#06B6D4','#6366F1','#3B82F6','#0284C7']`; titles `Down Payment→訂金, During Construction→施工期間, On Handover→交屋時`; phases `Construction Started→動工, Expected Completion→預計完工`.

**No-DLD-coverage caveat:** when `recent_transactions` is empty show `「{cityZh}」地區暫無 DLD 成交數據…` (transactions/PSF/rent analysis covers Dubai only). `cityZh`: `Dubai→杜拜, Abu Dhabi→阿布達比, Ras Al Khaimah→拉斯海瑪, Sharjah→沙迦`.

### 8.4 What Dubai does NOT have (don't invent it)

No doc-QA / official-documents tab, no 校網, no valuation engine, no verdict page (`verdict/{market}` redirects away for UAE in the reference), no discount price, no separate floor-plan tab (plans live inside unit cards), `marketYield()` = null in cross-market compare. `hkSizeBand`/`docQa` endpoints stay HK-gated.

---

## 9. Verification protocol (each phase gates the next)

1. **After Phase 1:** row-count + field-by-field spot checks vs reference DB (§4.3). Assert zero slug collisions post-flattening.
2. **After Phase 2:** tinker-driven payload checks (HK style):
   ```bash
   $PHP artisan tinker --execute="
   \$d = app(App\Actions\BuildAeProjectDetail::class)->execute(
       Src\Common\Country::where('iso2','AE')->first(), 'sol-properties-fairmont-residences-solara-tower', null, 1);
   print_r([\$d['calcSeed'] ?? null, \$d['market']['prediction'] ?? null]);"
   ```
   Compare against the LIVE peta-new page (investhink.ai `/new-projects/uae/{orig-slug}`) for the 3 curated + top-10 hotness projects: predicted value, value band, gross yield, market PSF, YoY, growth 1Y/5Y, rent by bed, transaction table rows, unit-type prices/sizes, payment milestones. **Caveat:** peta-new computes from LIVE RapidAPI while the reference DB snapshot is from 2026-07-09 — comparisons must be formula-parity on our freshly-harvested snapshots, plus tolerance for upstream drift; verify identical formulas produce identical numbers on identical payloads (fixture tests), and same-day harvest vs live page for absolute numbers.
3. **After Phase 5/6:** side-by-side UI pass on the same projects — list card contents/order (hotness desc), every tab, locked-state behaviour anonymous vs signed-in, bedroom switching, formatters (AED thousand separators, NT$ 萬, 坪 rounding).
4. **Regression locks:** run existing suites — `EngineRegressionLockTest`, `MarketCopyLockTest`, `CountryIsolationEngineTest` (MY byte-identical contract must be untouched), HK page smoke, `npm test`, `npm run build` is handled by the user (never run it — user rule; ask them to build for SSR checks).
5. **Master (Phase 4):** dry-run import diff, post-import counts, then the standard verification pages + `/ae/new-projects` against master-backed env.

---

## 10. Risks & gotchas (carry-overs from HK + new)

1. **Verbatim rule** (top of this doc) — before writing any port, read the reference method end-to-end and list every constant/filter/column it touches.
2. **Reference snapshot is stale** (max updatedAt 2026-07-09) and its scraper cadence is unknown — ask Wai Kit whether `uae:import-projects` still runs on prod; if AE inventory should be current, either wait for a refresh or harvest fresh data via RapidAPI ourselves (`UaeImportProjects` logic is portable).
3. **`ESTATE_UAE_RAPIDAPI_KEY` is a hard prerequisite for Phase 2** — not present in either local `.env`; source it from peta-new prod. Without it Phases 0–1 and 5 still proceed (list page needs no API).
4. **Long imports/harvests in a persistent shell** — the agent harness killed three HK imports mid-flight; everything must be idempotent + resumable (and is, if the patterns above are followed).
5. **Connection pinning** — any new model in the catalogue family needs BOTH `const CONNECTION` and `protected $connection` (a missing pin fails silently onto the default connection; verify with `(new Model)->getConnectionName()` at runtime). Expected: NO new models needed.
6. **Never `DB::transaction()` for catalogue writes** — always `DB::connection('catalogue')->transaction()` (master-plan trap 1).
7. **No cross-DB joins** — hotness ordering and federation merges must not join site DB ↔ catalogue (trap: `whereExists` across connections silently builds a cross-database subquery locally where both collapse to one DB, then breaks in prod).
8. **JSON null ≠ SQL NULL** in `fields` filters — HK lost a whole estate pool to `psf_median: null`; use `NULLIF(JSON_UNQUOTE(...), 'null')` when filtering source fields.
9. **Publication does not survive a master re-seed** — `catalogue:ae-publish-parity` must exist and be in the refresh runbook before cutover.
10. ⚠️ **The UAE analysis snapshots do NOT travel in a catalogue package.** `catalogue:export`
    deliberately drops every `uae-*` scoped source row and never exports `raw_payload` at all —
    and the UAE detail page computes entirely from those payloads. So an export → import
    cutover lands a UAE catalogue whose pages have nothing to render. The command written for
    exactly this gap is **`catalogue:copy-uae-snapshots`**, and it must run AFTER the import.
    It refuses while the catalogue connection resolves locally, so run it with
    `CATALOGUE_USE_DEFAULT_CONNECTION` unset.
11. **`docs/hk-handoff.md`, cited twice in this plan and by two live classes
    (`BuildAeCommunityAnalysis`, `PropertyFinderUaeAdapter`) as the source of the
    "verbatim-port rules", does not exist and never has.** It is NOT
    `docs/petahk-project-catalogue-handoff.md`. The reasoning behind the UAE field mapping is
    currently unrecorded.
10. **Cache namespaces** — version every computed-payload cache (HK used v1→v3 bumps to invalidate mid-fix results); `cache:clear` after any source/field logic change.
11. **`Home.vue` `'uae'` vs `'ae'`** mismatch (line ~384) — fix in Phase 0 or the globe badge misrenders.
12. **Migration hygiene** — data-only migrations, real dates (never future-dated), idempotent guards, prove on a scratch DB with `migrate:fresh` (`php scripts/check-migration-constants.php`).
13. **User rules:** no `npm run dev/build` (user builds), no private/protected methods in any class, `data_only()` before transactions, Repositories = writes only, no git offers.

---

## 11. Open decisions for the user (none block Phases 0–1)

1. **NT$/TWD secondary pricing** — port verbatim (8.6 hardcoded) or rely on the site currency switcher? (default: verbatim)
2. **Contact tab advisors** — port the Taiwan/LINE cards or substitute petav3 advisors/CTA? (default: structure verbatim, identities swapped — confirm)
3. **Chinese variant** — reference copy is zh-Hant; petav3 ships en/ms/zh_CN. Default: translate reference strings into `zh_CN.json` (HK precedent).
4. **Live-API mode** — is a request-time RapidAPI fallback wanted when a snapshot is missing/stale, or snapshot-only? (default: snapshot-only)
5. **Data freshness** — is the 2026-07-09 snapshot acceptable for launch, or do we re-harvest the full inventory from RapidAPI first?

---

## 12. Suggested build order (for the implementation session)

```
Phase 0  Market foundation  (countries row, market_sites, preset, Home.vue fix)      — small
Phase 1  Ingestion          (provider + adapter + sync + completeness/waivers)       — core data
Phase 5  List page          (BuildNewProjectListing AE branch + ae-publish-parity)   — visible early win, no API needed
Phase 2  Harvest + BuildAe* (RapidAPI snapshots, analysis actions, AI translations)  — needs API key
Phase 6  Detail page        (controller branch, ProjectDetailAe.vue + components)
Phase 3  Media → GCS        (slow bulk download; page renders url-fallback meanwhile)
Phase 4  Master migration   (export/import, Wai Kit coordination, runbook update)
    →   Verification pass at every phase boundary (§9)
```

---

# 12.9 ⚠️ THE REFERENCE MOVED — v2 re-port (2026-08-20)

The live investhink.ai UAE page runs code NEWER than every branch of the
local peta-new clone (unpushed prod deploy). Captured from the live
server-rendered HTML (scratchpad `live-uae.html` / `live-projectPage.js`)
and re-ported:

- **Eight tabs** (EN labels + lucide icons): Overview · Units & Price ·
  Investment · **vs Home** (scale icon — `AeCompareTab.vue`, fully static
  seed: Dubai vs China/HK/Taiwan five-row comparison + verdict + grouped
  bars/radar/5-yr projection SVGs, own 台灣中文/English toggle persisted in
  localStorage) · **Location** (renamed) · New Supply · Developer · Contact.
  `#hash` tab routing.
- **Invest tab rebuilt** (`AeInvestTab.vue` v2): Valuation·Rent / Returns
  sub-tab pills; investment-snapshot strip (6 KPIs incl. 5-yr growth);
  PropertyLab AI verdict line; 2×2 grid — comps map (`AeCompsMap.vue`,
  geocoded DLD pins, hover-linked to) the per-building PSF bar chart, the
  community PSF trend chart (Value/Rent + 2Y/5Y + hover tooltip), the
  yield-by-type table vs China (`CHINA_YIELD_BY_BED` = the reference's
  `__homeYieldByBed`); collapsible DLD table with est. rent / est. yield.
  Charts are verbatim string-SVG builders in `utils/aeCharts.js`.
- **calc changed in v2**: rent now prefers the by-bed community rent for the
  selected layout over `predicted_rent` (aeCalc.js + test locked).
- **Backend additions**: `market.summary.sale_psf`, `market.trends`
  (oneY/twoY/fiveY {period, psf} from the stored price-trends payload),
  `market.by_bed` (per-bed sale/rent/yield; missing sides backfilled from
  band-filtered comps median / community ROI, flagged `estimated`), and
  `recent_transactions[].lat/lng` from a new **harvest geocode step** (one
  Mapbox forward-geocode per unique building per community, stored as
  `bundle['geocodes']`; existing bundles lack the key → the next harvest
  pass adds it automatically via the incompleteness rule).
- The reference's zh-only leftover sections (overview facts, netCalc card,
  units/amenity/supply/developer/contact) are byte-identical to v1 — our
  t()-based bilingual versions stand.

# 13. IMPLEMENTATION STATUS (2026-08-19) + OPERATOR RUNBOOK

**All code for Phases 0–6 is written, lint-clean and smoke-tested.** What
remains is running the data commands (below) and the visual side-by-side pass.

## 13.1 What was built

| Piece | Files |
|---|---|
| Market foundation | migrations `2026_08_19_100001` (AE country + propertylabglobal pivot + resolver cache bust) and `2026_08_19_100002` (propertyfinder provider) · `LocalePreset::PRESETS['ae']` · `config/project_catalogue.php` (provider entry, AE waivers `floor_plans/completion/market_price`, `launch_providers`, staleness 2160h, `locale_presets.ae`) · `config/services.php → estate_uae` · `Home.vue` `'uae'`→`'ae'` fix |
| Ingestion | `PropertyFinderUaeAdapter` (951 rows, no isActive filter, external_id = ORIGINAL slug, raw facts as source fields, layouts → floor plans, hero/gallery/plan media) · `DataProvider::CODE_PROPERTYFINDER` |
| Slugs / publish | `catalogue:ae-apply-slugs` (feed slug flattened `/`→`-`, dry-run default) · `catalogue:ae-publish-parity` (whereNotNull imageUrl parity) |
| Snapshot engine | `EstateUaeApiClient` (verbatim HTTP port, 30-min cache) · `EstateUaeSnapshotStore` (scopes `uae-details` per project + `uae-community` per community on catalog_project_sources — zero migrations) · `catalogue:harvest-uae-details` (resumable, per-community bundles: tx beds 0–5+project beds+all, insights, trends, coord, supply) |
| Analysis (verbatim) | `BuildAeCommunityAnalysis` (analyze(): size bands, PSF window, median/percentile, prediction/yield/growth) · `BuildAeProjectAnalysis` (analyzeProject(): gallery/layouts/unit types incl. ×10.7639 re-multiply hack, tx backfill, payment, timeline, supply cap 8) · `BuildAeProjectDetail` (page seed + SEO) · `BuildAeDeveloperRecord` (raw-label matching) |
| Controller / routes | `ProjectDetailController` — `UNITED_ARAB_EMIRATES` branch → `showUae()` (+ Product JSON-LD SEO), `uaeAnalyze` (guest stripping verbatim + supply page-slug linking), `uaeTranslate` (members, AiClient, reference cache keys 30d), `uaeDeveloper` (members) · routes `POST uae-analyze` / `POST uae-translate` / `GET uae-developer` |
| AI prompts | `PROMPT_UAE_DESCRIPTION_ZH` / `PROMPT_UAE_COMMUNITY_ZH` + registry entries + `resources/prompts/uae_*.md` (reference system prompts verbatim; model pinnable on the AI Prompts page — pin gemini to match peta-new) |
| List page | `BuildNewProjectListing` AE branch — hotness DESC via correlated subquery (never a join), PHP merge-sort parity, bedrooms-array beds pill (0 = Studio), no sale-status badge, propertyfinder source eager-load |
| Detail page UI | `Pages/Main/Site/ProjectDetailAe.vue` + `Components/ProjectDetailAe/{AeHero, AeOverviewTab, AeUnitsTab, AeInvestTab, AeAmenityTab, AeSupplyTab, AeDeveloperTab, AeContactTab}` — 7 tabs in reference order, bed selector, lightbox, locked sections, Mapbox standard/pitch-50 + Tilequery POIs |
| Client maths | `utils/aeCalc.js` + `utils/aeFormat.js` (calc/netCalc/formatters to the digit) + **`utils/aeCalc.test.js` — 7/7 green**, anchored on the real Fairmont 1BR row |
| Translations | 124 new `zh_CN.json` entries (append-only diff) |
| Media | `PropertyFinderAssetFetcher` + `CatalogueMediaStorageService::storeUae()` + `catalogue:store-uae-media` |
| Master docs | `scripts/seed-master-catalogue.sh` + master plan §12.6: `catalogue:ae-publish-parity` added to the refresh runbook, `/ae/new-projects` to the verify pages |

## 13.2 ⚠️ THE ONE ENV FACT THAT MATTERS TOMORROW

**This box's `.env` points the `catalogue` connection at the REMOTE master**
(`master_projects` @ 34.87.149.195) — `CATALOGUE_USE_DEFAULT_CONNECTION` is
NOT set. A `catalogue:sync` run as-is writes STRAIGHT INTO THE SHARED MASTER
(verified: a 5-row smoke test landed there with dangling country/provider ids
and was fully deleted again — master holds 0 AE rows now).

**Every local-phase command below must run with the collapse flag**, either
per shell:

```bash
export CATALOGUE_USE_DEFAULT_CONNECTION=true    # then run the commands
```

or by uncommenting the flag in `.env` for the session (remember to remove it
before the master phase). Do NOT run `php artisan config:cache` while relying
on the per-shell export.

Smoke state already in the local DB: **8 AE projects** ingested, slugged and
published (idempotent — the full sync upserts them). Master: clean.

## 13.3 Local phase — run in a PERSISTENT shell (not the agent harness)

```bash
PHP=/opt/homebrew/opt/php@8.4/bin/php
export CATALOGUE_USE_DEFAULT_CONNECTION=true

# 0. migrations already ran locally (AE country id 3, provider id 8).
$PHP artisan migrate            # no-op if already run
$PHP artisan cache:clear        # market-site cache after the pivot row

# 1. full ingestion — 951 projects, 2,805 layouts (minutes)
$PHP artisan catalogue:sync propertyfinder --full

# 2. adopt the flattened feed slugs, then publish to parity
$PHP artisan catalogue:ae-apply-slugs           # review the dry-run, then:
$PHP artisan catalogue:ae-apply-slugs --apply
$PHP artisan catalogue:ae-publish-parity        # expect ~951

# 3. completeness picture (informational)
$PHP artisan catalogue:completeness --country=AE

# 4. LIST PAGE IS NOW LIVE — verify /ae/home and /ae/new-projects
#    (hotness-desc order, emirate chips Dubai 786 / Abu Dhabi 99 / RAK 64,
#    Studio beds pill, amber UAE ribbon on the Home featured cards)

# 5. detail-page snapshots — REQUIRES ESTATE_UAE_RAPIDAPI_KEY in .env
#    (copy from peta-new prod .env; ~950 details + ~7 calls per community)
$PHP artisan catalogue:harvest-uae-details      # resumable; hours with throttle

# 6. detail page verify (tinker + browser side-by-side vs investhink.ai):
$PHP artisan tinker --execute="
\$r = app(App\Actions\BuildAeProjectAnalysis::class)
    ->execute('sol-properties/fairmont-residences-solara-tower', 1);
print_r(\$r['market']['prediction']);"

# 7. media — LOCAL storage (owner decision 2026-08-19). MEDIA_DISK=public, so
#    files land in storage/app/public/catalogue/{uuid}/… and serve via the
#    existing public/storage symlink (/storage/… URLs pass isPublicMediaUrl;
#    verified). Heroes first — one per project brings every card online:
$PHP artisan catalogue:store-uae-media --kind=hero
$PHP artisan catalogue:store-uae-media          # gallery + floor plans
#    ⚠️ MASTER-PHASE CONSEQUENCE: each processed row's provider url is nulled
#    and its media row records disk=public — those paths do not exist on prod.
#    Before/at the master import, run a one-time REHOST step (small command to
#    write then): iterate MasterMedia where disk='public' AND
#    collection='catalog_media', re-put the local bytes to the GCS disk and
#    update the row's disk/path. The bytes are all on this laptop, so nothing
#    is lost — but skip the rehost and prod shows broken images.

# 8. AI translations: pin gemini on the two uae_* prompt keys at
#    /manage/integrations/ai/prompts to match peta-new's model, and ensure a
#    gemini key is saved in AI Providers. Translations generate lazily on
#    member views (cached 30 days under the reference cache keys).
```

## 13.4 Master phase — ONLY after local verification

```bash
unset CATALOGUE_USE_DEFAULT_CONNECTION          # catalogue = remote master again

# 1. master prerequisites — ids MUST MATCH local (countries.id 3, data_providers.id 8),
#    or the family's integer FKs diverge from every other deployment (§9.2):
#    INSERT INTO countries (id, iso2, name, currency_code, currency_symbol, locale, timezone,
#        is_active, is_default, created_at, updated_at)
#      VALUES (3,'AE','United Arab Emirates','AED','AED','en_AE','Asia/Dubai',1,0,NOW(),NOW());
#    INSERT INTO data_providers (id, code, name, is_active, created_at, updated_at)
#      VALUES (8,'propertyfinder','PropertyFinder',1,NOW(),NOW());
#    (verify the ids first: SELECT id FROM countries WHERE iso2='AE' on LOCAL, etc.)

# 2. cut the package from LOCAL and import into master
CATALOGUE_USE_DEFAULT_CONNECTION=true $PHP artisan catalogue:export /tmp/ae-catalogue.json --country=AE
$PHP artisan catalogue:import /tmp/ae-catalogue.json --dry-run   # read the report first
$PHP artisan catalogue:import /tmp/ae-catalogue.json

# 3. publish on master + verify
$PHP artisan catalogue:ae-publish-parity
# load /ae/new-projects against the master-backed env

# 4. TELL WAI KIT: master now carries provider 'propertyfinder' (AE). His
#    refresh contract (upsert by provider+external_id, preserve uuids, whole-
#    family copy) must include it or his next refresh drops/duplicates AE.
#    A future full re-seed via scripts/seed-master-catalogue.sh already
#    handles AE (countries + data_providers travel in the dump) — but
#    republish afterwards: catalogue:ae-publish-parity (see script output).
```

## 13.5 Verified during implementation

- `catalogue:sync propertyfinder` — field-by-field mapping checked against the
  reference row (name/slug/area/state/price/completion/coords/hotness/
  bedrooms/media/plan rows all correct).
- `ae-apply-slugs` + `ae-publish-parity` — 8/8 on the local smoke set.
- `BuildAeProjectDetail` seed — matches the reference `$seed` (developer
  resolves via the canonical pivot with raw-label fallback).
- Full `BuildAeProjectAnalysis` path against a synthetic API-shaped snapshot —
  every prediction figure hand-verified (median 2500 × 950, percentiles
  2300/2700, yield 3.7, growth 10/46.7, the 89→958 sqft re-multiply hack,
  supply self-exclusion, payment milestones).
- `aeCalc.test.js` 7/7 (calc/netCalc/formatters anchored on the real
  Fairmont ih_uae_layouts row) · existing `newProjects.test.js` 8/8 still
  green · all 9 new Vue SFCs compile · all PHP files lint clean · the three
  `uae-*` routes register · all four new artisan commands register.

## 13.6 Currency decision (settled 2026-08-19)

/ae defaults to **English + RM display** (owner choice — matches what most
visitors see on peta-new, whose default my-en preset renders the UAE list in
MYR). Implemented as `SetPublicSite::DISPLAY_CURRENCY_DEFAULTS['AE'=>'MYR']` —
a DISPLAY-only override; `countries.currency_code` stays AED because it is the
conversion SOURCE for every stored price and the `$from` argument the listing
passes to `Currency::fmt()`. AED stays one click away in the header switcher;
`/hk` and `/my` re-baselining is unchanged. The detail page's invest tab stays
AED + NT$ (hardcoded in the reference page itself, ported verbatim).

NB for testing: a browser that already visited /ae carries `site_country=ae` +
`site_cur=AED` cookies and will NOT re-baseline until it switches country —
clear cookies (or visit /my then /ae) to see the RM default.

## 13.7 Open items / decisions still with the owner

1. `npm run build` + SSR restart — owner-run (never run by the agent); the
   new page needs a build before it renders.
2. Language: AE authors English keys with zh_CN entries (HK precedent). The
   reference is zh-Hant/Taiwan-audience; revisit if a zh-Hant catalog lands.
3. Contact tab ships the reference's own DEMO advisor cards (its footnote
   says so), now with the reference's ACTUAL photo files (copied to
   public/images/uae-agents/) and its exact fallbacks — swap in real advisors
   when the business supplies them. Exact-match pass 2026-08-19 also added
   the reference's per-tab lucide icons, the 返回項目列表 back-link, the
   預計 {date} 交屋 word order, the 「city」 brackets, the 依起價 X 試算
   suffix, and the top-16 sticky offset under the site header.
4. Reference snapshot freshness (2026-07-09) — confirm with Wai Kit whether
   the UAE scrape reruns, or re-harvest the inventory via RapidAPI.
5. `phpunit` could not run on this machine (the known petav3_testing
   credential issue from the HK handoff) — production-shaped tinker checks
   above stand in for it.
