# Property Match — 公开测评页 `/property-match` 完整说明

> 文档对象:`https://propertylabglobal.com/property-match`(公开,无需登录)
> 代码版本:2026-07-22 · branch `dev`
> 配套文档:`/property-match-admin.md`(管理后台说明)

---

## 1. 这个页面是什么

**Property Match** 是一个「2 分钟买家测评」的 lead-generation 落地页。访客回答最多 4 个关于
financing 和投资目标的问题,系统用一套 decision engine 从 4 个楼盘
(**Peel Lane / The Andaman Sunway / Binastra Cochrane / KLCC Non-HDA Business Suite**)
中配对出推荐的 unit type,然后引导他约 Zoom 咨询(经 WhatsApp 确认)。

核心设计理念:**lead 在按下 Submit 的那一刻就已存入数据库**(在揭晓结果和 Zoom CTA 之前),
所以即使访客看完结果就离开、不约 Zoom,这条 lead 也不会丢失。

- 单页应用(SPA),**Alpine.js 3** 驱动,无整页刷新
- **中英双语**(默认 `zh` 中文),右上角 EN / 中文 切换,全程可切换
- 深色金色质感 UI(墨蓝底 `#0E1526` + 金 `#C9A24B`),字体 Fraunces(标题衬线)+ Manrope + Noto Sans SC
- Mobile-first,`max-width: 560px` 居中排版

---

## 2. 文件与路由一览

| 类型 | 位置 |
| --- | --- |
| Routes | `routes/web.php:2960-2962`(public,无 auth middleware) |
| Controller | `app/Http/Controllers/PublicSite/PropertyMatchController.php` |
| View(整页) | `resources/views/public/property-match/survey.blade.php` |
| 文案/结果数据 | `app/Support/PropertyMatchData.php`(输出 JS blob:`PM_T` / `PM_R` / `PM_LOAN` / `PM_PRICE`) |
| Model | `src/InvestHink/PropertyMatchSubmission.php` |
| Migration | `database/migrations/2026_07_19_100000_create_property_match_submissions_table.php` 等 4 个 |

### Routes

| Method | URI | Action | Route name | 用途 |
| --- | --- | --- | --- | --- |
| GET | `/property-match` | `survey()` | `public.property-match` | 渲染测评页 |
| POST | `/property-match/submit` | `store()` | `public.property-match.submit` | 保存 lead + 配对结果,返回 JSON |
| POST | `/property-match/booked` | `booked()` | `public.property-match.booked` | 标记该提交已约 Zoom(带时段) |

三条 route 都**无需登录**;POST 走标准 Laravel CSRF(页面 `<meta name="csrf-token">` + fetch header `X-CSRF-TOKEN`)。

### 外部依赖(CDN)

- Alpine.js 3(`cdn.jsdelivr.net/npm/alpinejs@3.x.x`)
- intl-tel-input 18.2.1(国际电话输入,含 utils.js)
- Google Fonts(Fraunces / Manrope / Noto Sans SC)
- `ipapi.co/country/` — 按 IP 自动侦测电话国码(失败 fallback `MY` 马来西亚)

---

## 3. 用户流程(前端 step 状态机)

Alpine 组件 `survey()` 用一个 `step` 变量驱动 9 个画面:

```
intro → contact → quota ─┬→ (dp) ─┬→ loan ─┬→ (priority) ─┬→ (freehold) ─┐
                         │        │        │              │              ▼
                         └────────┴────────┴──────────────┴───────→ submit → result → book → done
```

### Step 详解

1. **`intro` 开场** — 标语「选对 property,靠 *decision*,不靠运气。」+ 简介(4 个问题、同顾问一套 framework、免费 2 分钟)。CTA:「开始配对我的 property →」。
2. **`contact` 联系资料** — 揭晓结果前先收集:
   - 姓名(`lead.name`)
   - WhatsApp 号码(`lead.phone`)— intl-tel-input,分离国码显示,preferred countries:MY / CN / SG / HK / TW,按 IP 自动选国家;提交时转成 **E.164 完整号码**(`pmIti.getNumber()`)
   - Email(`lead.email`)— 前端只做 `/.+@.+\..+/` 粗验
   - 三项都有效才能点「开始测评 →」(否则按钮半透明不可点)
   - 隐私说明:「你的资料绝不外泄,只用于准备与发送你的 property match。」
3. **`quota` 第 1 题:90% loan quota 状况**(进度条 25%,标题会带上用户 first name)
   | 选项 | 记录 answer | 下一步 |
   | --- | --- | --- |
   | 我还没买过任何 property | `quota:'yes'` | → loan |
   | 已买 1 间,还剩最后一个 90% quota | `quota:'yes'` | → loan |
   | 还有 quota,但想 reserve 起来 | `quota:'no', dp:'no', reserve:true` | **直接 → submit**(短路) |
   | 两个 90% quota 都用完了 | `quota:'no'` | → dp |
4. **`dp` 第 1b 题:能否接受 20% downpayment**(仅 quota 用完者看到;约 RM100k–150k)
   - 可以 → `dp:'yes'` → loan
   - 不了,保留现金 → `dp:'no'` → **直接 submit**(将配 KLCC)
5. **`loan` 第 2 题:贷款能批多少**(进度 50%)— 4 档:`above850` / `650to850` / `550to650` / `below550`。选 `below550` **直接 submit**(将配 Andaman 2 房);其余 → priority。
6. **`priority` 第 3 题:更看重什么**(进度 75%)— `growth`(capital growth 增值)或 `roi`(稳定 ROI 全托管)。选 growth **且** loan ≥ 650k 才进入第 4 题;否则直接 submit。
7. **`freehold` 第 4 题:freehold 重不重要**(进度 100%)— `yes` / `no`,答完 → submit。
8. **`submit` 确认页** — 回显整条答案 trail(每答一题就在页顶累积一颗金色 chip)+ 联系资料摘要。按「Submit,揭晓我的 match →」触发 `submitLead()`(见 §5)。保存中按钮显示「正在保存你的测评…」并 disabled 防重复提交。**就算 POST 失败,前端也照样进入 result 页**(catch 后继续),不挡用户。
9. **`result` 结果页** — 奶白色「证书卡」样式展示配对结果(见 §4 的 10 种结果):标签(tag)、楼盘标题、unit 描述、**价格**(金色粗体)、tenure badge、ROI badge、3 条推荐理由(✦ 列表);若有数字 ROI,右上角有旋转 -8° 的绿色双圈「印章」动画(`~9% EST. ROI`)。下方 CTA:「安排我的 Zoom call →」→ book;或「重新测评」→ restart。
10. **`book` 预约页** — 「Zoom 上 15 分钟。」选一个偏好时段 chip(工作日白天 / 工作日晚上 / 周末),按「通过 WhatsApp 确认 →」:
    - 打开 `wa.me/601133167831`(PropertyLab 咨询专线,写死在 `window.PM_WA`),消息预填:答案 trail、推荐楼盘 + unit、姓名、email、偏好时段
    - 同时调用 `markBooked()` → POST `/property-match/booked`(带 `savedId` + slot)把 DB 状态从 `new` 改为 `booked`
11. **`done` 完成页** — 绿色 ✓,「顾问会在 WhatsApp 确认你的 Zoom 时段。你的 match — **{楼盘}** — 已经保存。」CTA:「帮朋友再测一次」→ 全部重置回 intro。

其他交互:页头有「重新开始」按钮(intro 以外的所有 step 显示);每次切 step 自动 `scrollTo(0,0)`;`prefers-reduced-motion` 时关闭动画。

---

## 4. Decision engine(配对逻辑)

**前后端各有一份完全相同的逻辑**:前端 `decideKey()`(blade 内 JS,用于即时显示),后端
`PropertyMatchController::decideKey()`(PHP,**server-side 重算,作为 source of truth 存入 `match_key`**,
防止前端被篡改)。

```
if quota == 'no' && dp == 'no':
    reserve 为 true → klccReserve,否则 → klcc
if loan == 'below550'          → andaman2br
if loan == '550to650':
    priority == 'growth' → peel3br,否则 → andaman3br
(此后 loan 为 650to850 或 above850;typeB = above850)
if priority == 'roi'           → andamanManaged
if freehold == 'yes'           → typeB ? binastraB : binastraA
否则(freehold == 'no')        → typeB ? growthOpenB : growthOpenA
```

### 10 种结果(`PM_R` + `PM_PRICE`,中英双语文案)

| key | 楼盘 / 标题 | Unit | Tenure | ROI | Tag(中文) | 价格(中文) |
| --- | --- | --- | --- | --- | --- | --- |
| `klccReserve` | KLCC Non-HDA Business Suite | RM500k 起 · Tier-1 地址 | Non-HDA · 商业地契 | — | Quota 战略布局 | RM500k 起 |
| `klcc` | KLCC Non-HDA Business Suite | RM500k 起 · Tier-1 地址 | Non-HDA · 商业地契 | — | 资本保值 | RM500k 起 |
| `andaman2br` | The Andaman, Sunway | 2房 · Room Rental 策略 | Leasehold | ~9% | 最高 yield 入场 | 约 RM470k · Type B 2房 |
| `peel3br` | Peel Lane | 3房 · Dual Key | Leasehold | ~8% | 增值 corridor | 约 RM620k · 3房 Leasehold |
| `andaman3br` | The Andaman, Sunway | 3房 · Dual Key | Leasehold | ~9% | 稳定 + 全托管 | 约 RM570k · Type C Dual Key |
| `andamanManaged` | The Andaman, Sunway | 托管投资 Unit | Leasehold | net(8–9% net-positive) | 全托管 | 约 RM570k · Type C Dual Key |
| `binastraB` | Binastra Cochrane | Type B · Dual Key | **Freehold** | —(文案:最低 7–8%) | Freehold 增值 | 约 RM800k · Type B |
| `binastraA` | Binastra Cochrane | Type A · Dual Key | **Freehold** | —(文案:最低 7–8%) | Freehold 增值 | 约 RM680k · Type A |
| `growthOpenB` | Peel Lane or Binastra Type B | Dual Key 双选项 | Leasehold 或 Freehold | —(最低 7–8%) | 增值 — 由你选 | Peel Lane 约 RM620k · Binastra Type B 约 RM800k |
| `growthOpenA` | Peel Lane or Binastra Type A | Dual Key 双选项 | Leasehold 或 Freehold | —(最低 7–8%) | 增值 — 由你选 | Peel Lane 约 RM620k · Binastra Type A 约 RM680k |

每个结果都带 3 条 `reasons` 推荐理由(中英各一套),完整文案见
`app/Support/PropertyMatchData.php`(`PM_R` 对象)。所有测评问题文案、按钮文字、提示语也都集中在同一文件的
`PM_T`(en/zh 两套),模板本身不含文案 — **要改文案只改 `PropertyMatchData.php` 一个文件**。

---

## 5. 后端逻辑(`PublicSite\PropertyMatchController`)

### `store()` — POST `/property-match/submit`

1. **Validation**(Laravel validate):`name` required ≤120;`phone` required ≤40;`email` required + 格式 ≤190。不合规返回 422。
2. **answers 白名单过滤**:只保留 `quota / dp / loan / priority / freehold / reserve` 六个 key(`array_intersect_key`),其他一律丢弃。
3. `lang` 只接受 `en` / `zh`,否则回落 `zh`。
4. `trail` 最多存 12 条(`array_slice`)。
5. **`match_key` 由服务器用同一套 `decideKey()` 重算**(防篡改);`match_title / match_unit / match_tag` 保留客户端传来的展示用副本。
6. `status` 固定为 `new`;记录请求 `ip`。
7. `DB::transaction()` 内 `PropertyMatchSubmission::create($data)`。
8. 返回 `{ ok: true, id, match_key }` — 前端把 `id` 存为 `savedId` 供后续 booked 调用。

### `booked()` — POST `/property-match/booked`

- 按 `id` 找提交;**只有当前状态是 `new` 时**才改为 `booked`(不会把 admin 已推进的
  `contacted` / `closed` 倒退回去),并存 `slot`(截取 40 字符)。
- 永远返回 `{ ok: true }`(找不到也不报错,静默)。

---

## 6. 数据库

**Table `property_match_submissions`**(model `Src\InvestHink\PropertyMatchSubmission`,
注意:extends `Illuminate\Model` 非 Diver base — InvestHink 模块惯例):

| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | bigIncrements | |
| `name` | string(120) | 姓名 |
| `phone` | string(40) | WhatsApp 号(E.164) |
| `email` | string(190), index | |
| `lang` | string(8), default `zh` | 提交时的界面语言 |
| `answers` | text → array cast | `{quota,dp,loan,priority,freehold,reserve}` |
| `trail` | text → array cast | 答案 chip 文字数组(≤12) |
| `match_key` | string(40), index | **服务器重算**的结果 key |
| `match_title` / `match_unit` / `match_tag` | string | 客户端展示副本 |
| `slot` | string(40) | 买家自选的偏好 Zoom 时段(公开页写入) |
| `appointment_at` | datetime, index | 管理员排的正式约见时间(admin 页写入,见 admin 文档) |
| `status` | string(20), default `new`, index | `new` → `booked` → `contacted` → `closed` |
| `appointment` / `closer` | string(40) | 分配的员工(admin 页写入) |
| `tags` | text → array cast | JSON 标签(admin 页写入) |
| `ip` | string(45) | 提交 IP |
| `created_at` / `updated_at` | timestamps | |

**Status 常量**(`STATUSES`,含 UI 颜色):`new`=新提交(blue)、`booked`=已约 Zoom(amber)、
`contacted`=已联系(violet)、`closed`=已成交(green)。

---

## 7. 值得注意的实现细节 / 已知限制

- **Lead 永不丢失**:submit 成功即入库;之后约不约 Zoom 只是 status 差异。
- **提交失败也放行看结果**:fetch `.catch()` 后照样进 result — 体验优先,极端情况下(网络断)会有「看了结果但 DB 没记录」的 lead。
- **无防重复提交限制**:同一个人可多次测评,后台按 email/phone 去重合并展示(见 admin 文档 §人去重)。
- **`booked()` 无所有权校验**:理论上知道 id 的人可以标记任意 `new` 提交为 booked(危害仅限状态 + slot 字段,且不能倒退状态)。
- **WhatsApp 专线**写死在 view:`601133167831`(`survey.blade.php` 内 `window.PM_WA`)。
- **电话国码探测**依赖 `ipapi.co` 免费接口,失败 fallback MY;号码在 submit 时才转 E.164,如果 utils.js 未加载则按原始输入保存。
- 页面 title:`PropertyLab · Property Match 配对`;品牌显示「PropertyLab AI」。
